Compare commits
84 Commits
v1.0.0
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d442e193dd | ||
|
|
7d6794f368 | ||
|
|
6c83e12bf5 | ||
|
|
bc1dfb6dde | ||
|
|
8d6d167b33 | ||
|
|
1e7c80ca9f | ||
|
|
b92765ce7f | ||
|
|
daae32ce07 | ||
|
|
ab984ff415 | ||
|
|
d49a16db6e | ||
|
|
3a97d673f6 | ||
|
|
b6e6ee0927 | ||
|
|
22b603258a | ||
|
|
1885004874 | ||
|
|
d5deb75231 | ||
|
|
c313764b61 | ||
|
|
63f419228e | ||
|
|
f84c0ab777 | ||
|
|
7792a78c00 | ||
|
|
7aec1e452a | ||
|
|
8f9910a3fd | ||
|
|
2392808b82 | ||
|
|
9567501369 | ||
|
|
9964614b5e | ||
|
|
e40daddf0d | ||
|
|
b10b8dd7d5 | ||
|
|
5aeff7585b | ||
|
|
3307ec687e | ||
|
|
98875044d6 | ||
|
|
bd8235c70f | ||
|
|
3c1d616dc1 | ||
|
|
28d86aff16 | ||
|
|
1c0c9afd17 | ||
|
|
5bb1c3a2d1 | ||
|
|
2d4f670365 | ||
|
|
792ed7faa2 | ||
|
|
272c832c43 | ||
|
|
ae0dfceba1 | ||
|
|
129094a39e | ||
|
|
9308c60aa0 | ||
|
|
614f66c433 | ||
|
|
fdfd49be63 | ||
|
|
71bd310459 | ||
|
|
ec2029a942 | ||
|
|
86480dec07 | ||
|
|
839d4a89bf | ||
|
|
9a356a228f | ||
|
|
e3b8365ea2 | ||
|
|
0bc147cbc5 | ||
|
|
9b063afba0 | ||
|
|
7ef35fa8ee | ||
|
|
769517f7bf | ||
|
|
7a775ee9c5 | ||
|
|
9c9ef05d13 | ||
|
|
4c32a460d3 | ||
|
|
9d986f4b5a | ||
|
|
851c73e326 | ||
|
|
07719e940a | ||
|
|
b807c10d7a | ||
|
|
8a8b336237 | ||
|
|
3a3708b147 | ||
|
|
efc2753e45 | ||
|
|
824564dac6 | ||
|
|
31af14a2ca | ||
|
|
3937c678f3 | ||
|
|
bc7616df42 | ||
|
|
c45cb34a35 | ||
|
|
984cf734fe | ||
|
|
0bb6cf7849 | ||
|
|
c6c8e20683 | ||
|
|
21fef999fb | ||
|
|
c9a2db3df2 | ||
|
|
f0d981ad00 | ||
|
|
afd568588d | ||
|
|
98e5048f2c | ||
|
|
12425d147f | ||
|
|
3f2817d6c3 | ||
|
|
2587576514 | ||
|
|
f410373f7b | ||
|
|
ba2663552d | ||
|
|
f3ba9de06f | ||
|
|
d84a0ed956 | ||
|
|
dd147a24b4 | ||
|
|
010582d702 |
@@ -16,13 +16,17 @@ The application entry point is [src/index.ts](mdc:src/index.ts), which sets up t
|
||||
- **services/**: Service layer for external API interactions
|
||||
- **config/**: Configuration management
|
||||
- **utils/**: Utility functions
|
||||
- **llm/**: Multi-provider LLM gateway and provider adapters
|
||||
- **db/**: SQLite database layer for LLM configuration
|
||||
- **crypto/**: Encryption utilities for API key storage
|
||||
- **agent/**: Multi-agent review engine (planner, specialists, judge)
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- [package.json](mdc:package.json): Project dependencies and scripts
|
||||
- [tsconfig.json](mdc:tsconfig.json): TypeScript compiler configuration
|
||||
- [Dockerfile](mdc:Dockerfile): Container configuration
|
||||
- [kubernetes.yaml](mdc:kubernetes.yaml): Kubernetes deployment configuration
|
||||
- [kubernetes.yaml](mdc:k8s/gitea-assistant.yaml): Kubernetes deployment configuration
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
|
||||
@@ -37,6 +37,15 @@ The application follows a clean, layered architecture:
|
||||
- Centralizes application configuration from environment variables
|
||||
- Manages Feishu webhook configurations
|
||||
|
||||
4. **LLM Gateway** ([src/llm/](mdc:src/llm))
|
||||
- Multi-provider LLM abstraction layer
|
||||
- Provider adapters for OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini
|
||||
- Role-based model routing (legacy, planner, specialist, judge, embedding)
|
||||
|
||||
5. **Database Layer** ([src/db/](mdc:src/db))
|
||||
- SQLite-based LLM provider and role configuration
|
||||
- API key encryption with AES-256-GCM
|
||||
|
||||
4. **Utilities**
|
||||
- [src/utils/logger.ts](mdc:src/utils/logger.ts): Custom logging utilities
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ alwaysApply: true
|
||||
- **Runtime**: Bun (JavaScript/TypeScript runtime)
|
||||
- **Language**: TypeScript
|
||||
- **Framework**: Hono (lightweight web framework)
|
||||
- **API Integration**: OpenAI API, Gitea API
|
||||
- **API Integration**: LLM Gateway (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini), Gitea API
|
||||
- **Containerization**: Docker, Kubernetes
|
||||
|
||||
## Key Dependencies
|
||||
@@ -22,7 +22,9 @@ From [package.json](mdc:package.json):
|
||||
- **hono**: Lightweight, ultrafast web framework
|
||||
- **@hono/zod-validator**: Schema validation for Hono
|
||||
- **zod**: TypeScript-first schema validation
|
||||
- **openai**: OpenAI API client
|
||||
- **openai**: OpenAI API client (used for OpenAI Compatible and Responses providers)
|
||||
- **@anthropic-ai/sdk**: Anthropic Messages API client
|
||||
- **@google/genai**: Google Gemini API client
|
||||
- **axios**: HTTP client for API requests
|
||||
- **dotenv**: Environment variable management
|
||||
- **lodash-es**: Utility library
|
||||
@@ -36,10 +38,12 @@ From [package.json](mdc:package.json):
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
The application uses environment variables for configuration, which are processed in [src/config/index.ts](mdc:src/config/index.ts). Key configurations include:
|
||||
The application uses a **DB-first** configuration approach (Portainer model):
|
||||
|
||||
- Gitea API settings
|
||||
- OpenAI API settings
|
||||
- Custom prompts for AI review
|
||||
- Server configuration
|
||||
- Webhook security
|
||||
- **Environment variables** (minimal, infrastructure-level only):
|
||||
- `PORT`: Server port
|
||||
- `DATABASE_PATH`: SQLite file path (optional, default: `./data/assistant.db`)
|
||||
- `MASTER_KEY_PATH`: Encryption key path (optional, default: `./data/master.key`)
|
||||
- **Web UI + SQLite DB** ([src/db/](mdc:src/db)): All runtime config — Gitea, Feishu, webhook secret, admin password, review engine, memory settings — managed via Admin Dashboard
|
||||
- **First-boot seed**: `configManager.seedDefaults()` auto-generates secrets and seeds defaults on first run
|
||||
- **bun:sqlite**: Embedded database for all configuration persistence (encrypted for sensitive values)
|
||||
|
||||
@@ -7,7 +7,7 @@ alwaysApply: true
|
||||
|
||||
## Overview
|
||||
|
||||
The AI Code Review system is the core feature of this application. It automatically analyzes code changes in Pull Requests and commits, providing insightful feedback using OpenAI's language models.
|
||||
The AI Code Review system is the core feature of this application. It automatically analyzes code changes in Pull Requests and commits, providing insightful feedback using pluggable LLM providers via the LLM Gateway.
|
||||
|
||||
## Key Components
|
||||
|
||||
@@ -36,7 +36,7 @@ The AI Code Review system is the core feature of this application. It automatica
|
||||
- Generate AI prompts with context
|
||||
|
||||
3. **AI Review**:
|
||||
- Send processed data to OpenAI API
|
||||
- Route request through LLM Gateway to configured provider
|
||||
- Generate summary feedback
|
||||
- Generate line-level comments
|
||||
|
||||
|
||||
@@ -5,27 +5,29 @@ alwaysApply: false
|
||||
---
|
||||
# Deployment and Configuration
|
||||
|
||||
## Environment Variables
|
||||
## Environment Variables (Minimal)
|
||||
|
||||
The application is configured through environment variables, defined in [src/config/index.ts](mdc:src/config/index.ts):
|
||||
Only three infrastructure-level settings are read from environment variables. Everything else is managed through the Admin Dashboard Web UI:
|
||||
|
||||
- **Gitea Configuration**:
|
||||
- `GITEA_API_URL`: Gitea API endpoint URL
|
||||
- `GITEA_ACCESS_TOKEN`: Access token for Gitea API
|
||||
- `PORT`: Server port (default: `5174`)
|
||||
- `DATABASE_PATH`: SQLite database file path (optional, default: `./data/assistant.db`)
|
||||
- `MASTER_KEY_PATH`: Encryption master key file path (optional, default: `./data/master.key`)
|
||||
|
||||
- **OpenAI Configuration**:
|
||||
- `OPENAI_BASE_URL`: OpenAI API base URL
|
||||
- `OPENAI_API_KEY`: API key for OpenAI
|
||||
- `OPENAI_MODEL`: Model to use (e.g., "gpt-4o")
|
||||
## First-Boot Seeding
|
||||
|
||||
- **Server Configuration**:
|
||||
- `PORT`: Server port (default: 3000)
|
||||
- `WEBHOOK_SECRET`: Secret for webhook verification
|
||||
On first startup with an empty `system_settings` table, `configManager.seedDefaults()` automatically:
|
||||
- Generates `JWT_SECRET` and `WEBHOOK_SECRET` (64-char hex via `crypto.randomBytes(32)`)
|
||||
- Seeds all config fields with their default values
|
||||
- Sets `ADMIN_PASSWORD` to `password` (must be changed via Web UI)
|
||||
|
||||
- **Custom Prompts**:
|
||||
- `CUSTOM_SUMMARY_PROMPT`: Custom prompt for summary reviews
|
||||
- `CUSTOM_LINE_COMMENT_PROMPT`: Custom prompt for line comments
|
||||
## Web UI Configuration
|
||||
|
||||
All runtime settings are managed through the Admin Dashboard at `http://your-server:PORT`:
|
||||
- Gitea connection (API URL, access token, admin token)
|
||||
- Security settings (webhook secret, admin password, JWT secret)
|
||||
- Review engine settings (engine mode, parallelism, file limits, confidence)
|
||||
- Feishu integration (webhook URL and secret)
|
||||
- Memory/learning features (Qdrant URL, enable flags)
|
||||
## Deployment Options
|
||||
|
||||
### Local Development
|
||||
@@ -48,22 +50,22 @@ The [Dockerfile](mdc:Dockerfile) provides containerization support:
|
||||
docker build -t gitea-assistant:latest .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 --env-file .env gitea-assistant:latest
|
||||
docker run -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant:latest
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
The [kubernetes.yaml](mdc:kubernetes.yaml) and [kubernetes.yaml.template](mdc:kubernetes.yaml.template) files provide Kubernetes deployment configuration.
|
||||
The [kubernetes.yaml](mdc:k8s/gitea-assistant.yaml) file provides Kubernetes deployment configuration. Persistent storage is required for the `/app/data` directory.
|
||||
|
||||
Deployment can be managed using:
|
||||
```bash
|
||||
# Apply configuration
|
||||
kubectl apply -f kubernetes.yaml
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
### Webhook Setup
|
||||
|
||||
Configure Gitea webhooks to point to the `/webhook/gitea` endpoint with:
|
||||
- Content type: application/json
|
||||
- Secret: matching WEBHOOK_SECRET environment variable
|
||||
- Secret: matching the Webhook Secret configured in the Admin Dashboard
|
||||
- Events: Pull Request and Status events
|
||||
|
||||
@@ -31,11 +31,16 @@ When contributing to this project, adhere to these structural guidelines:
|
||||
- External API interactions belong in the [services/](mdc:src/services) directory
|
||||
- Each service should have a clear, single responsibility
|
||||
|
||||
3. **Configuration**:
|
||||
- Environment-based configurations go in [config/index.ts](mdc:src/config/index.ts)
|
||||
- Use environment variables for configurable values
|
||||
3. **LLM Layer**:
|
||||
- LLM provider adapters in [llm/](mdc:src/llm)
|
||||
- Database layer in [db/](mdc:src/db)
|
||||
- Encryption utilities in [crypto/](mdc:src/crypto)
|
||||
|
||||
4. **Utils**:
|
||||
4. **Configuration**:
|
||||
- Environment-based configurations go in [config/index.ts](mdc:src/config/index.ts)
|
||||
- LLM provider settings are managed through Web UI + SQLite DB
|
||||
|
||||
5. **Utils**:
|
||||
- Reusable utility functions belong in [utils/](mdc:src/utils)
|
||||
- Logging should use the custom logger from [utils/logger.ts](mdc:src/utils/logger.ts)
|
||||
|
||||
|
||||
63
.env.example
@@ -1,53 +1,14 @@
|
||||
# Gitea配置
|
||||
GITEA_API_URL=http://localhost:3000/api/v1
|
||||
GITEA_ACCESS_TOKEN=your_gitea_token
|
||||
|
||||
# OpenAI配置
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
CUSTOM_SUMMARY_PROMPT=your_custom_prompt
|
||||
CUSTOM_LINE_COMMENT_PROMPT=your_custom_prompt
|
||||
|
||||
# 飞书配置
|
||||
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_webhook_token
|
||||
FEISHU_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# 应用配置
|
||||
PORT=3000
|
||||
# 建议使用以下命令生成一个安全的随机字符串作为webhook密钥:
|
||||
# 在Linux/Mac终端: openssl rand -hex 32
|
||||
# 或者在Node.js中: require('crypto').randomBytes(32).toString('hex')
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
PORT=5174
|
||||
# 可选,默认为 ./data/assistant.db
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# 可选,默认 info,可选值:debug/info/warn/error
|
||||
# 开发环境建议:LOG_LEVEL=info
|
||||
# 生产环境建议:LOG_LEVEL=error
|
||||
# LOG_LEVEL=info
|
||||
# 必填,运行 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Agent审查配置(默认关闭,开启请设置为agent)
|
||||
REVIEW_ENGINE=legacy
|
||||
REVIEW_WORKDIR=/tmp/gitea-assistant
|
||||
REVIEW_MODEL_PLANNER=gpt-4o-mini
|
||||
REVIEW_MODEL_SPECIALIST=gpt-4o-mini
|
||||
REVIEW_MODEL_JUDGE=gpt-4o-mini
|
||||
REVIEW_MAX_PARALLEL_RUNS=2
|
||||
REVIEW_MAX_FILES_PER_RUN=200
|
||||
REVIEW_MAX_FILE_CONTENT_CHARS=40000
|
||||
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.8
|
||||
REVIEW_ENABLE_HUMAN_GATE=true
|
||||
REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
|
||||
REVIEW_COMMAND_TIMEOUT_MS=10000
|
||||
|
||||
# 向量记忆和学习系统配置(可选,第二阶段功能)
|
||||
# Qdrant向量数据库URL(如果不配置则禁用记忆系统)
|
||||
QDRANT_URL=http://localhost:6333
|
||||
# 是否启用记忆系统(需要先配置QDRANT_URL)
|
||||
ENABLE_MEMORY=false
|
||||
# Few-shot学习示例数量(0-20)
|
||||
FEW_SHOT_EXAMPLES_COUNT=10
|
||||
|
||||
# Reflection和Debate配置(可选,第三阶段功能)
|
||||
# 是否启用Reflection自我批评机制(提升审查质量)
|
||||
ENABLE_REFLECTION=false
|
||||
# Reflection最大轮次(1-5)
|
||||
MAX_REFLECTION_ROUNDS=2
|
||||
# 是否启用Debate多代理辩论机制(提升高严重性问题准确性)
|
||||
ENABLE_DEBATE=false
|
||||
# Debate触发阈值(high=仅高严重性, medium=高和中等严重性)
|
||||
DEBATE_THRESHOLD=high
|
||||
# 所有其他配置(Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等)
|
||||
# 均通过 Web 管理后台进行配置。
|
||||
# 启动服务后访问 http://localhost:5174 进行配置。
|
||||
|
||||
28
.github/workflows/ci.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -15,11 +15,14 @@ jobs:
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
@@ -28,3 +31,24 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: cd frontend && bunx playwright install --with-deps chromium
|
||||
|
||||
- name: Print visual stack versions
|
||||
run: cd frontend && bun --version && bunx playwright --version && bunx playwright install --list
|
||||
|
||||
- name: Install deterministic CJK fonts for visual snapshots
|
||||
run: sudo apt-get update && sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji fonts-liberation
|
||||
|
||||
- name: Run visual regression
|
||||
run: bun run ui:visual
|
||||
|
||||
- name: Upload Playwright artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
|
||||
49
.github/workflows/release.yml
vendored
@@ -40,35 +40,66 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Run semantic-release
|
||||
run: bunx semantic-release
|
||||
id: semantic
|
||||
uses: codfish/semantic-release-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
HUSKY: 0
|
||||
HUSKY_SKIP_HOOKS: 1
|
||||
|
||||
# Docker build and push
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package-version
|
||||
- name: Derive Docker tags from semantic-release
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
id: docker-tags
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: $VERSION"
|
||||
VERSION="${{ steps.semantic.outputs.release-version }}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "semantic-release did not provide release-version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
TAGS="ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}"
|
||||
else
|
||||
MAJOR="${VERSION%%.*}"
|
||||
REST="${VERSION#*.}"
|
||||
MINOR="${REST%%.*}"
|
||||
|
||||
TAGS=$(printf '%s\n' \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}.${MINOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}")
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$TAGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Release version: ${VERSION}"
|
||||
echo "Docker tags to push:"
|
||||
echo "$TAGS"
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${{ steps.package-version.outputs.version }}
|
||||
tags: ${{ steps.docker-tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
16
.gitignore
vendored
@@ -4,3 +4,19 @@ dist/
|
||||
config-overrides.json
|
||||
.sisyphus/
|
||||
e2e/.env.e2e
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
|
||||
# Frontend build artifacts
|
||||
public/
|
||||
|
||||
# Test temporaries
|
||||
/tmp/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Lock files (frontend has its own)
|
||||
frontend/package-lock.json
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
bunx --bun @biomejs/biome check --staged --files-ignore-unknown=true --no-errors-on-unmatched
|
||||
87
CHANGELOG.md
@@ -1,3 +1,90 @@
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **db:** self-heal missing repository prompt schema ([b6e6ee0](https://github.com/jeffusion/gitea-ai-assistant/commit/b6e6ee0927eb757b86ee426bf8eed84ae633621a))
|
||||
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](https://github.com/jeffusion/gitea-ai-assistant/commit/3a97d673f671752a2e7f676fb0074b413c2e40cc))
|
||||
* **repo:** add structured diagnostics for repository list failures ([22b6032](https://github.com/jeffusion/gitea-ai-assistant/commit/22b603258ac32e70653aa1a05032a91e8ad23f89))
|
||||
|
||||
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **repo:** add project-level review prompt with UI redesign ([d5deb75](https://github.com/jeffusion/gitea-ai-assistant/commit/d5deb752317508aa47470a20fec4d11a5d2b66b7))
|
||||
|
||||
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** source Docker tags from semantic-release version ([f84c0ab](https://github.com/jeffusion/gitea-ai-assistant/commit/f84c0ab7770b73032b331c82cf8f87f1e8b281ff))
|
||||
|
||||
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **lint:** apply biome cleanup for notification modules ([7aec1e4](https://github.com/jeffusion/gitea-ai-assistant/commit/7aec1e452a04d3dbf935837e9b8e96107466c487))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add dedicated notification management menu and test panel ([9964614](https://github.com/jeffusion/gitea-ai-assistant/commit/9964614b5ebb7972e2b35f3fc673f626372f6552))
|
||||
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](https://github.com/jeffusion/gitea-ai-assistant/commit/e40daddf0dd168c19251cdb84a3b6b136814f553))
|
||||
|
||||
## [1.1.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.0...v1.1.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** guard husky prepare for production installs ([5aeff75](https://github.com/jeffusion/gitea-ai-assistant/commit/5aeff7585b465fa9479c538b67b99978d12455b1))
|
||||
|
||||
# [1.1.0](https://github.com/Jeffusion/gitea-ai-assistant/compare/v1.0.0...v1.1.0) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** fix rg args ordering in function reference search tool ([f410373](https://github.com/Jeffusion/gitea-ai-assistant/commit/f410373f7b1b935047fb5f50fad7c95a870704a8))
|
||||
* **agent:** improve specialist agent JSON resilience and finding schema ([2587576](https://github.com/Jeffusion/gitea-ai-assistant/commit/2587576514b353ff3a1314b54cf85c977a66f62e))
|
||||
* **ci:** stabilize visual regression environment ([9887504](https://github.com/Jeffusion/gitea-ai-assistant/commit/98875044d6b514d253a90cfcc445ac2f5253b278))
|
||||
* **config:** make persistOverrides resilient to read-only filesystems ([3f2817d](https://github.com/Jeffusion/gitea-ai-assistant/commit/3f2817d6c306c024a7200f36e087dd15c97bcad8))
|
||||
* **config:** silently skip readonly fields on save instead of rejecting ([12425d1](https://github.com/Jeffusion/gitea-ai-assistant/commit/12425d147f3ec0c202b2d9a347836d5515e9b702))
|
||||
* **docker:** add git, ca-certificates, and ripgrep to production image ([ba26635](https://github.com/Jeffusion/gitea-ai-assistant/commit/ba2663552d77321bb91ef112fa4bbebb65a5585c))
|
||||
* **frontend:** standardize favicon/title, 401 redirect, SPA root route, and theme switching ([5bb1c3a](https://github.com/Jeffusion/gitea-ai-assistant/commit/5bb1c3a2d182dcde65667c39f2a61eaf1d43b706))
|
||||
* **k8s:** extract Secret to separate file to fix kustomize apply ([e3b8365](https://github.com/Jeffusion/gitea-ai-assistant/commit/e3b8365ea2992162b2f575bb84af73352ef375ca))
|
||||
* **k8s:** remove stale GITEA_ACCESS_TOKEN/GITEA_API_URL/QDRANT_URL from k8s config ([9b063af](https://github.com/Jeffusion/gitea-ai-assistant/commit/9b063afba0046d50ba691efe1f45159b97c2149e))
|
||||
* **k8s:** use writable emptyDir volume for config overrides ([98e5048](https://github.com/Jeffusion/gitea-ai-assistant/commit/98e5048f2c898966ab59c2c806477bac2e443869))
|
||||
* **lint:** resolve biome violations across src modules ([3c1d616](https://github.com/Jeffusion/gitea-ai-assistant/commit/3c1d616dc180d53232eccca05255ca853084ab23))
|
||||
* make all config consumers read dynamically instead of caching at module load ([9a356a2](https://github.com/Jeffusion/gitea-ai-assistant/commit/9a356a228f11c07495e5d60eb6c6554a42ec4434))
|
||||
* make FEISHU_WEBHOOK_URL optional to prevent startup crash ([d84a0ed](https://github.com/Jeffusion/gitea-ai-assistant/commit/d84a0ed95614fc954fdf7b7f6725ede4e32d76f3))
|
||||
* remove isDev branches that caused production to use mock test data ([f3ba9de](https://github.com/Jeffusion/gitea-ai-assistant/commit/f3ba9de06f5f51ebf44e13a7e1db8f9d264d9034))
|
||||
* **test:** update specialist-agent-react tests for LLMGateway API ([824564d](https://github.com/Jeffusion/gitea-ai-assistant/commit/824564dac6bddd7439ef40f3c0da946d9634201f))
|
||||
* **ui:** align card headers and stabilize themed layout polish ([28d86af](https://github.com/Jeffusion/gitea-ai-assistant/commit/28d86aff16f954cd18c0e46b86325a13fc6c0949))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **config:** add Codex engine configuration fields ([129094a](https://github.com/Jeffusion/gitea-ai-assistant/commit/129094a39e77b0bb66b052ed19bb8969d1757503))
|
||||
* **config:** add global prompt setting injected into all LLM calls ([afd5685](https://github.com/Jeffusion/gitea-ai-assistant/commit/afd568588d1722206314009cd0b9060125fac883))
|
||||
* **config:** migrate all runtime settings from env vars to SQLite DB ([4c32a46](https://github.com/Jeffusion/gitea-ai-assistant/commit/4c32a460d39e277b1ee59fec31a171f162c83f22))
|
||||
* **db:** add SQLite database layer with encrypted secret storage ([21fef99](https://github.com/Jeffusion/gitea-ai-assistant/commit/21fef999fbca56c07b93cd9109d8e7dfd50bbf31))
|
||||
* **frontend:** update config UI for DB-first config architecture ([9c9ef05](https://github.com/Jeffusion/gitea-ai-assistant/commit/9c9ef05d13962586cbd0decb2377d003a8901679))
|
||||
* **llm:** add LLM config REST API controller ([c6c8e20](https://github.com/Jeffusion/gitea-ai-assistant/commit/c6c8e2068331bdfe344f92f53cd6fffc7758cfac))
|
||||
* **llm:** add pluggable multi-provider LLM architecture ([c9a2db3](https://github.com/Jeffusion/gitea-ai-assistant/commit/c9a2db3df2c73ef22cb2c32fd80bdf36bfa1e697))
|
||||
* **llm:** add resilience layer with rate limiting and retry ([839d4a8](https://github.com/Jeffusion/gitea-ai-assistant/commit/839d4a89bfd763d7fe693c00c3aa9b1becd34c58))
|
||||
* **review/codex:** add Codex review engine with MCP tools ([614f66c](https://github.com/Jeffusion/gitea-ai-assistant/commit/614f66c433a93fc2373a9bf8d2319bd5cb35473b))
|
||||
* **review:** add incremental review with snapshot refs ([9308c60](https://github.com/Jeffusion/gitea-ai-assistant/commit/9308c60aa014c0dd016d984a6b3d1cfb1e3a9379))
|
||||
* **review:** add token-aware context control with tokenlens ([ec2029a](https://github.com/Jeffusion/gitea-ai-assistant/commit/ec2029a94261169832477f9c7aaa4ee1ad1adefc))
|
||||
* **review:** add triage agent for smart specialist routing ([86480de](https://github.com/Jeffusion/gitea-ai-assistant/commit/86480dec076661315f725b286e65d57092ceb30b))
|
||||
* **review:** add workspace cleanup on PR close and scheduled stale cleanup ([792ed7f](https://github.com/Jeffusion/gitea-ai-assistant/commit/792ed7faa2b89bb0f4c84dcf032e30a34286b77b))
|
||||
* **review:** remove legacy mode and harden agent/codex pipeline ([1c0c9af](https://github.com/Jeffusion/gitea-ai-assistant/commit/1c0c9afd1779a15e55edc7a854924e86cbb50cf3))
|
||||
* **ui:** add frontend test infrastructure with vitest ([bc7616d](https://github.com/Jeffusion/gitea-ai-assistant/commit/bc7616df424e26d6b20105f6401329e63e9013ef))
|
||||
* **ui:** add LLM provider management frontend ([c45cb34](https://github.com/Jeffusion/gitea-ai-assistant/commit/c45cb34a358393a0b6e0523fd282ee3d49fbff82))
|
||||
* **ui:** add review config page with engine selector ([ae0dfce](https://github.com/Jeffusion/gitea-ai-assistant/commit/ae0dfceba1e626fddf75d60e982f81c953aaddd6))
|
||||
* **ui:** replace hardcoded model lists with dynamic tokenlens API ([71bd310](https://github.com/Jeffusion/gitea-ai-assistant/commit/71bd310459901ed665eb1337d17bb4bec9ca9c3e))
|
||||
|
||||
# 1.0.0 (2026-03-03)
|
||||
|
||||
|
||||
|
||||
24
Dockerfile
@@ -25,9 +25,26 @@ COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
|
||||
# ---- Stage 3: Production ----
|
||||
# ---- Stage 3: Codex CLI Binary ----
|
||||
FROM debian:bookworm-slim AS codex-downloader
|
||||
|
||||
ARG CODEX_VERSION=0.111.0
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \
|
||||
curl -fsSL "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${ARCH}-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ && \
|
||||
mv /usr/local/bin/codex-${ARCH}-unknown-linux-musl /usr/local/bin/codex && \
|
||||
chmod +x /usr/local/bin/codex
|
||||
|
||||
|
||||
# ---- Stage 4: Production ----
|
||||
FROM oven/bun:1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||
@@ -37,6 +54,9 @@ COPY --from=backend-builder /app/tsconfig.json .
|
||||
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
EXPOSE 3000
|
||||
# Codex CLI binary (statically linked musl build)
|
||||
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
203
README.md
@@ -2,178 +2,103 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
AI-powered code review assistant for Gitea. Automatically reviews Pull Requests and commits using OpenAI, providing intelligent code quality analysis with both summary comments and line-level feedback.
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
**[中文文档](./docs/README.zh-CN.md)**
|
||||
- English docs: [./docs/README.md](./docs/README.md)
|
||||
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
|
||||
|
||||
## Features
|
||||
## Why this project
|
||||
|
||||
- 🤖 **AI Code Review** - Automatic review of PRs and commits using OpenAI models
|
||||
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
|
||||
- 🔄 **Dual Review Engines** - Legacy (simple) or Agent-based (multi-agent) review modes
|
||||
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
|
||||
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and configuration
|
||||
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
|
||||
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
|
||||
- 🧠 **Two review engines**: `agent` (native Agent pipeline) and `codex` (Codex CLI pipeline)
|
||||
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- 📍 **Actionable output**: summary comments and line-level findings
|
||||
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
|
||||
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
|
||||
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
|
||||
|
||||
## Architecture
|
||||
## Product screenshot
|
||||
|
||||
> Dashboard screenshot is generated from local dev service.
|
||||
|
||||

|
||||
|
||||
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
|
||||
|
||||
## Architecture (high-level)
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea Server │────▶│ Gitea Assistant │────▶│ OpenAI API │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Admin Dashboard │
|
||||
│ (React SPA) │
|
||||
└──────────────────┘
|
||||
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
||||
|
|
||||
+-> Admin Dashboard (React)
|
||||
```
|
||||
|
||||
### Review Engines
|
||||
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
|
||||
|
||||
| Engine | Description | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| `legacy` | Single-pass AI review with summary + line comments | Simple, fast reviews |
|
||||
| `agent` | Multi-agent orchestration with specialists, reflection, and debate | Deep, comprehensive analysis |
|
||||
## Quick start (minimal)
|
||||
|
||||
## Quick Start
|
||||
### 1) Prerequisites
|
||||
|
||||
### Prerequisites
|
||||
- Bun >= 1.2.5
|
||||
- Reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- Gitea instance with API access
|
||||
- OpenAI API key
|
||||
|
||||
### Installation
|
||||
### 2) Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `.env` with your settings:
|
||||
If lifecycle scripts are disabled in your environment, run:
|
||||
|
||||
```bash
|
||||
# Gitea
|
||||
GITEA_API_URL=https://your-gitea-instance.com/api/v1
|
||||
GITEA_ACCESS_TOKEN=your_gitea_token
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# Security
|
||||
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
|
||||
|
||||
# Admin Dashboard
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
### Running
|
||||
### 3) Minimal `.env`
|
||||
|
||||
```bash
|
||||
bun run dev # Development mode
|
||||
bun run start # Production mode
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
### Setting Up Webhooks
|
||||
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
|
||||
1. Access `http://your-server:3000`
|
||||
2. Log in with `ADMIN_PASSWORD`
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as `WEBHOOK_SECRET`
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GITEA_API_URL` | Gitea API endpoint | Required |
|
||||
| `GITEA_ACCESS_TOKEN` | Token for code review (read + comment permissions) | Required |
|
||||
| `GITEA_ADMIN_TOKEN` | Token for webhook management (optional) | - |
|
||||
| `OPENAI_BASE_URL` | OpenAI API base URL | `https://api.openai.com/v1` |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | Required |
|
||||
| `OPENAI_MODEL` | Model to use | `gpt-4o-mini` |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `WEBHOOK_SECRET` | Webhook signature secret | Required |
|
||||
|
||||
### Custom Prompts
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CUSTOM_SUMMARY_PROMPT` | Override the default summary review prompt |
|
||||
| `CUSTOM_LINE_COMMENT_PROMPT` | Override the default line comment prompt |
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ADMIN_PASSWORD` | Dashboard login password | `password` |
|
||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||
|
||||
### Feishu Integration
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FEISHU_WEBHOOK_URL` | Feishu bot webhook URL |
|
||||
| `FEISHU_WEBHOOK_SECRET` | Feishu webhook secret (optional) |
|
||||
|
||||
### Agent Review Engine
|
||||
|
||||
Enable with `REVIEW_ENGINE=agent` for advanced multi-agent reviews:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `REVIEW_ENGINE` | Engine mode (`legacy` or `agent`) | `legacy` |
|
||||
| `REVIEW_WORKDIR` | Working directory for repo clones | `/tmp/gitea-assistant` |
|
||||
| `REVIEW_MODEL_PLANNER` | Planner model | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_SPECIALIST` | Specialist model | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_JUDGE` | Judge model | `gpt-4o-mini` |
|
||||
| `REVIEW_MAX_PARALLEL_RUNS` | Max concurrent tasks | `2` |
|
||||
| `REVIEW_MAX_FILES_PER_RUN` | Max files per review | `200` |
|
||||
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | Min confidence for auto-publish | `0.8` |
|
||||
| `REVIEW_ENABLE_HUMAN_GATE` | Enable human approval | `true` |
|
||||
|
||||
### Memory & Learning (Experimental)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QDRANT_URL` | Qdrant vector database URL | - |
|
||||
| `ENABLE_MEMORY` | Enable memory system | `false` |
|
||||
| `ENABLE_REFLECTION` | Enable self-critique | `false` |
|
||||
| `ENABLE_DEBATE` | Enable multi-agent debate | `false` |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
### 4) Run
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 --env-file .env gitea-assistant
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
### 5) Configure in Admin UI
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
|
||||
|
||||
- Configure Gitea API + tokens
|
||||
- Configure webhook secret
|
||||
- Configure LLM providers/models
|
||||
- Configure review engine and policy
|
||||
|
||||
### 6) Add webhook in Gitea
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content-Type: `application/json`
|
||||
- Secret: same as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
## Progressive disclosure: detailed docs
|
||||
|
||||
- [Documentation index](./docs/README.md)
|
||||
- [Getting started details](./docs/getting-started.md)
|
||||
- [Configuration reference](./docs/configuration.md)
|
||||
- [Review engines](./docs/review-engines.md)
|
||||
- [Deployment (Docker / Compose / Kubernetes)](./docs/deployment.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
237
bun.lock
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ai-review",
|
||||
"name": "gitea-assistant",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
},
|
||||
@@ -19,19 +23,25 @@
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.6",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"semantic-release": "^24.2.9",
|
||||
"typescript": "^5.8.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||
@@ -52,8 +62,12 @@
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@google/genai": ["@google/genai@1.44.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
@@ -76,15 +90,37 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
|
||||
|
||||
"@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="],
|
||||
|
||||
"@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.2", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA=="],
|
||||
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.16.2", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.0.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-Zm4wEZURrZ24a+Hmm4l1QQYjiz975Ep3vF0yzWR7ICGcxittNz47YK2iBOk8kb8qseCu8pg7WmO1HOIsO8alvw=="],
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
@@ -108,16 +144,28 @@
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
|
||||
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
|
||||
|
||||
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
|
||||
|
||||
"@tokenlens/helpers": ["@tokenlens/helpers@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0" } }, "sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ=="],
|
||||
|
||||
"@tokenlens/models": ["@tokenlens/models@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -142,14 +190,28 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.8.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A=="],
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
@@ -178,9 +240,9 @@
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"conventional-changelog-angular": ["conventional-changelog-angular@8.2.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-4YB1zEXqB17oBI8yRsAs1T+ZhbdsOgJqkl6Trz+GXt/eKf1e4jnA0oW+sOd9BEENzEViuNW0DNoFFjSf3CeC5Q=="],
|
||||
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA=="],
|
||||
|
||||
"conventional-changelog-writer": ["conventional-changelog-writer@8.3.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-l5hDOHjcTUVtnZJapoqXMCJ3IbyF6oV/vnxKL13AHulFH7mDp4PMJARxI7LWzob6UDDvhxIUWGTNUPW84JabQg=="],
|
||||
"conventional-changelog-writer": ["conventional-changelog-writer@8.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g=="],
|
||||
|
||||
"conventional-commits-filter": ["conventional-commits-filter@5.0.0", "", {}, "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q=="],
|
||||
|
||||
@@ -196,6 +258,8 @@
|
||||
|
||||
"crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
@@ -206,12 +270,16 @@
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="],
|
||||
@@ -240,10 +308,14 @@
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -254,22 +326,30 @@
|
||||
|
||||
"find-versions": ["find-versions@6.0.0", "", { "dependencies": { "semver-regex": "^4.0.5", "super-regex": "^1.0.0" } }, "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"from2": ["from2@2.3.0", "", { "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" } }, "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g=="],
|
||||
|
||||
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
|
||||
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
@@ -280,6 +360,12 @@
|
||||
|
||||
"git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
@@ -296,7 +382,7 @@
|
||||
|
||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||
"hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
|
||||
|
||||
"hook-std": ["hook-std@4.0.0", "", {}, "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ=="],
|
||||
|
||||
@@ -310,6 +396,8 @@
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"import-from-esm": ["import-from-esm@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" } }, "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g=="],
|
||||
@@ -346,20 +434,30 @@
|
||||
|
||||
"issue-parser": ["issue-parser@7.0.1", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"java-properties": ["java-properties@1.0.2", "", {}, "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-with-bigint": ["json-with-bigint@3.5.7", "", {}, "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="],
|
||||
@@ -380,6 +478,8 @@
|
||||
|
||||
"lodash.uniqby": ["lodash.uniqby@4.7.0", "", {}, "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="],
|
||||
@@ -404,8 +504,12 @@
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
@@ -424,15 +528,17 @@
|
||||
|
||||
"normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="],
|
||||
|
||||
"npm": ["npm@10.9.4", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.1", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.2", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.4.1", "ci-info": "^4.2.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.1", "libnpmexec": "^9.0.1", "libnpmfund": "^6.0.1", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.1", "libnpmpublish": "^10.0.1", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.5", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.2.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.0", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.1", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.3", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.1", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA=="],
|
||||
"npm": ["npm@10.9.5", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.2", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.3", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.2", "libnpmexec": "^9.0.2", "libnpmfund": "^6.0.2", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.2", "libnpmpublish": "^10.0.2", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.9", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.5.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.1", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.4", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.2", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-tFABtwt8S5KDs6DKs4p8uQ+u+8Hpx4ReD6bmkrPzPI0hsYkRWIkY/esz6ZtHyHvqVOltTB9DM/812Lx++SIXRw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"openai": ["openai@4.87.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ=="],
|
||||
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
|
||||
"p-each-series": ["p-each-series@3.0.0", "", {}, "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw=="],
|
||||
|
||||
@@ -450,10 +556,14 @@
|
||||
|
||||
"p-reduce": ["p-reduce@2.1.0", "", {}, "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
"p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
@@ -468,6 +578,8 @@
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -476,16 +588,28 @@
|
||||
|
||||
"pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
@@ -494,15 +618,23 @@
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="],
|
||||
|
||||
@@ -524,6 +656,8 @@
|
||||
|
||||
"skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="],
|
||||
@@ -542,10 +676,14 @@
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
@@ -566,6 +704,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
|
||||
|
||||
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
|
||||
@@ -574,23 +714,26 @@
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="],
|
||||
|
||||
@@ -622,6 +765,10 @@
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
@@ -636,6 +783,12 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
|
||||
|
||||
"@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="],
|
||||
@@ -652,8 +805,6 @@
|
||||
|
||||
"@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
@@ -662,6 +813,14 @@
|
||||
|
||||
"env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
|
||||
"fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
|
||||
@@ -678,7 +837,7 @@
|
||||
|
||||
"npm/@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||
|
||||
"npm/@npmcli/arborist": ["@npmcli/arborist@8.0.1", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/map-workspaces": "^4.0.1", "@npmcli/metavuln-calculator": "^8.0.0", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.1", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", "@npmcli/run-script": "^9.0.1", "bin-links": "^5.0.0", "cacache": "^19.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", "npm-package-arg": "^12.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.1", "pacote": "^19.0.0", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", "walk-up-path": "^3.0.1" }, "bundled": true, "bin": { "arborist": "bin/index.js" } }, "sha512-ZyJWuvP+SdT7JmHkmtGyElm/MkQZP/i4boJXut6HDgx1tmJc/JZ9OwahRuKD+IyowJcLyB/bbaXtYh+RoTCUuw=="],
|
||||
"npm/@npmcli/arborist": ["@npmcli/arborist@8.0.2", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/map-workspaces": "^4.0.1", "@npmcli/metavuln-calculator": "^8.0.0", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.1", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", "@npmcli/run-script": "^9.0.1", "bin-links": "^5.0.0", "cacache": "^19.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", "npm-package-arg": "^12.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.1", "pacote": "^19.0.0", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "promise-retry": "^2.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", "walk-up-path": "^3.0.1" }, "bundled": true, "bin": { "arborist": "bin/index.js" } }, "sha512-BN7B/7QJrbRMITJujeYWTx1PEdUwTuJykfEdm+AAljLirAJ7j6Afh9lsHvbkvGbxaN5SqZhwIET5oRg+9osJJA=="],
|
||||
|
||||
"npm/@npmcli/config": ["@npmcli/config@9.0.0", "", { "dependencies": { "@npmcli/map-workspaces": "^4.0.1", "@npmcli/package-json": "^6.0.1", "ci-info": "^4.0.0", "ini": "^5.0.0", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-P5Vi16Y+c8E0prGIzX112ug7XxqfaPFUVW/oXAV+2VsxplKZEnJozqZ0xnK8V8w/SEsBf+TXhUihrEIAU4CA5Q=="],
|
||||
|
||||
@@ -838,19 +997,19 @@
|
||||
|
||||
"npm/libnpmaccess": ["libnpmaccess@9.0.0", "", { "dependencies": { "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-mTCFoxyevNgXRrvgdOhghKJnCWByBc9yp7zX4u9RBsmZjwOYdUDEBfL5DdgD1/8gahsYnauqIWFbq0iK6tO6CQ=="],
|
||||
|
||||
"npm/libnpmdiff": ["libnpmdiff@7.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^2.3.0", "diff": "^5.1.0", "minimatch": "^9.0.4", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "tar": "^6.2.1" }, "bundled": true }, "sha512-CPcLUr23hLwiil/nAlnMQ/eWSTXPPaX+Qe31di8JvcV2ELbbBueucZHBaXlXruUch6zIlSY6c7JCGNAqKN7yaQ=="],
|
||||
"npm/libnpmdiff": ["libnpmdiff@7.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^2.3.0", "diff": "^5.1.0", "minimatch": "^9.0.4", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "tar": "^6.2.1" }, "bundled": true }, "sha512-dAJThCk2WvMhrrJlfCQidVMcs8TOQWPCdP73e0/uTGRqulAxV2i3OqVc6j4ja8c/owwF4GrWo+eip/UMEWGJsQ=="],
|
||||
|
||||
"npm/libnpmexec": ["libnpmexec@9.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", "ci-info": "^4.0.0", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "proc-log": "^5.0.0", "read": "^4.0.0", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-+SI/x9p0KUkgJdW9L0nDNqtjsFRY3yA5kQKdtGYNMXX4iP/MXQjuXF8MaUAweuV6Awm8plxqn8xCPs2TelZEUg=="],
|
||||
"npm/libnpmexec": ["libnpmexec@9.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/run-script": "^9.0.1", "ci-info": "^4.0.0", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "proc-log": "^5.0.0", "read": "^4.0.0", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-9C6mZlJKYkmIJlfnIx2Qd65lDkOkiGonMRejBhOKbetrTpRFnUuEg7utQh3basaThR8wTphSDKWH1t48PduCjQ=="],
|
||||
|
||||
"npm/libnpmfund": ["libnpmfund@6.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1" }, "bundled": true }, "sha512-UBbHY9yhhZVffbBpFJq+TsR2KhhEqpQ2mpsIJa6pt0PPQaZ2zgOjvGUYEjURYIGwg2wL1vfQFPeAtmN5w6i3Gg=="],
|
||||
"npm/libnpmfund": ["libnpmfund@6.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2" }, "bundled": true }, "sha512-uwbQ7qfXQVDvd1gOVPU45yUhG/H5i/JCejewOcje2k2SleR/KxQG3ntHmDGx8jazfpFH4upHiNy7QxnzA0+kLA=="],
|
||||
|
||||
"npm/libnpmhook": ["libnpmhook@11.0.0", "", { "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-Xc18rD9NFbRwZbYCQ+UCF5imPsiHSyuQA8RaCA2KmOUo8q4kmBX4JjGWzmZnxZCT8s6vwzmY1BvHNqBGdg9oBQ=="],
|
||||
|
||||
"npm/libnpmorg": ["libnpmorg@7.0.0", "", { "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-DcTodX31gDEiFrlIHurBQiBlBO6Var2KCqMVCk+HqZhfQXqUfhKGmFOp0UHr6HR1lkTVM0MzXOOYtUObk0r6Dg=="],
|
||||
|
||||
"npm/libnpmpack": ["libnpmpack@8.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0" }, "bundled": true }, "sha512-E53w3QcldAXg5cG9NpXZcsgNiLw5AEtu7ufGJk6+dxudD0/U5Y6vHIws+CJiI76I9rAidXasKmmS2mwiYDncBw=="],
|
||||
"npm/libnpmpack": ["libnpmpack@8.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/run-script": "^9.0.1", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0" }, "bundled": true }, "sha512-2lYa08FlZMcMkxPSOEkAqj74Id+LvEUfSKfyYZi3BNjF7TTk8QQTh7oFsAGqUClbXiQ8HJiGYaX2OhYZGU92hQ=="],
|
||||
|
||||
"npm/libnpmpublish": ["libnpmpublish@10.0.1", "", { "dependencies": { "ci-info": "^4.0.0", "normalize-package-data": "^7.0.0", "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1", "proc-log": "^5.0.0", "semver": "^7.3.7", "sigstore": "^3.0.0", "ssri": "^12.0.0" }, "bundled": true }, "sha512-xNa1DQs9a8dZetNRV0ky686MNzv1MTqB3szgOlRR3Fr24x1gWRu7aB9OpLZsml0YekmtppgHBkyZ+8QZlzmEyw=="],
|
||||
"npm/libnpmpublish": ["libnpmpublish@10.0.2", "", { "dependencies": { "ci-info": "^4.0.0", "normalize-package-data": "^7.0.0", "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1", "proc-log": "^5.0.0", "semver": "^7.3.7", "sigstore": "^3.0.0", "ssri": "^12.0.0" }, "bundled": true }, "sha512-Q+PlGO6vOZDlZ6jKPDqDLYbARfV5OBusmJZj9GPbNUiys8OK6/yrwJ8ty8ibbc4GkMspqgOMdJ/1dcJwhtpkDg=="],
|
||||
|
||||
"npm/libnpmsearch": ["libnpmsearch@8.0.0", "", { "dependencies": { "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-W8FWB78RS3Nkl1gPSHOlF024qQvcoU/e3m9BGDuBfVZGfL4MJ91GXXb04w3zJCGOW9dRQUyWVEqupFjCrgltDg=="],
|
||||
|
||||
@@ -1022,12 +1181,16 @@
|
||||
|
||||
"npm/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"pino-abstract-transport/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="],
|
||||
|
||||
"semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="],
|
||||
@@ -1040,8 +1203,12 @@
|
||||
|
||||
"signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
@@ -1050,6 +1217,10 @@
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
|
||||
|
||||
"@semantic-release/github/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="],
|
||||
@@ -1072,8 +1243,6 @@
|
||||
|
||||
"@semantic-release/npm/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
|
||||
"cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
@@ -1098,7 +1267,7 @@
|
||||
|
||||
"npm/@npmcli/metavuln-calculator/pacote": ["pacote@20.0.0", "", { "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/package-json": "^6.0.0", "@npmcli/promise-spawn": "^8.0.0", "@npmcli/run-script": "^9.0.0", "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^12.0.0", "npm-packlist": "^9.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", "tar": "^6.1.11" }, "bin": { "pacote": "bin/index.js" } }, "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A=="],
|
||||
|
||||
"npm/cacache/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
|
||||
"npm/cacache/tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="],
|
||||
|
||||
"npm/cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
@@ -1108,7 +1277,7 @@
|
||||
|
||||
"npm/minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"npm/node-gyp/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
|
||||
"npm/node-gyp/tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="],
|
||||
|
||||
"npm/spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="],
|
||||
|
||||
|
||||
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
root = "./src"
|
||||
@@ -46,22 +46,14 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-test_key}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||
- FEISHU_WEBHOOK_URL=http://localhost:9999/noop
|
||||
- PORT=3000
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
- REVIEW_ENGINE=agent
|
||||
- REVIEW_WORKDIR=/tmp/e2e-review
|
||||
- REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.5
|
||||
- REVIEW_ENABLE_HUMAN_GATE=false
|
||||
- REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
|
||||
- REVIEW_COMMAND_TIMEOUT_MS=30000
|
||||
ports:
|
||||
- "3334:3000"
|
||||
- "3334:5174"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
@@ -8,15 +8,17 @@ services:
|
||||
|
||||
container_name: gitea-assistant
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5174:5174"
|
||||
volumes:
|
||||
- ./config-overrides.json:/app/config-overrides.json
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -32,3 +34,7 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
assistant_data:
|
||||
driver: local
|
||||
|
||||
20
docs/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Documentation
|
||||
|
||||
This project keeps the root `README.md` concise and moves implementation/deployment details here.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Getting started](./getting-started.md)
|
||||
- [Configuration reference](./configuration.md)
|
||||
- [Review engines](./review-engines.md)
|
||||
- [Deployment](./deployment.md)
|
||||
- [Screenshot gallery](./screenshots.md)
|
||||
|
||||
## Architecture & design
|
||||
|
||||
- [Notification service refactoring](./design/notification-service-refactoring.md)
|
||||
- [UI theme language](./design/ui-theme-language.md)
|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [README.zh-CN.md](./README.zh-CN.md)
|
||||
@@ -1,180 +1,24 @@
|
||||
# Gitea AI Assistant
|
||||
# 文档中心
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档。
|
||||
|
||||
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,使用 OpenAI 提供智能代码质量分析,支持总体评论和行级反馈。
|
||||
## 快速导航
|
||||
|
||||
**[English Documentation](../README.md)**
|
||||
- [快速开始](./getting-started.zh-CN.md)
|
||||
- [配置参考](./configuration.zh-CN.md)
|
||||
- [审查引擎](./review-engines.zh-CN.md)
|
||||
- [部署指南](./deployment.zh-CN.md)
|
||||
- [截图集](./screenshots.zh-CN.md)
|
||||
|
||||
## 功能特点
|
||||
## 架构与设计
|
||||
|
||||
- 🤖 **AI 代码审查** - 使用 OpenAI 模型自动审查 PR 和提交
|
||||
- 📝 **行级评论** - 针对具体代码变更的精确反馈
|
||||
- 🔄 **双引擎模式** - Legacy(简单)或 Agent(多代理)审查模式
|
||||
- 🔔 **飞书通知** - PR 事件通知集成
|
||||
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和配置的 Web 界面
|
||||
- 🔐 **安全验证** - HMAC-SHA256 签名验证
|
||||
- [通知服务重构设计](./design/notification-service-refactoring.md)
|
||||
- [UI 主题语言设计](./design/ui-theme-language.md)
|
||||
|
||||
## 架构设计
|
||||
## 产品截图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ OpenAI API │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 管理后台 │
|
||||
│ (React SPA) │
|
||||
└──────────────────┘
|
||||
```
|
||||

|
||||
|
||||
### 审查引擎对比
|
||||
## 语言切换
|
||||
|
||||
| 引擎 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `legacy` | 单次 AI 审查,生成总结和行级评论 | 简单、快速的审查 |
|
||||
| `agent` | 多代理编排,支持专家、反思和辩论 | 深度、全面的分析 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- OpenAI API 密钥
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# Gitea
|
||||
GITEA_API_URL=https://your-gitea-instance.com/api/v1
|
||||
GITEA_ACCESS_TOKEN=your_gitea_token
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# 安全
|
||||
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
|
||||
|
||||
# 管理后台
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
```
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
### 配置 Webhook
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:3000`
|
||||
2. 使用 `ADMIN_PASSWORD` 登录
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 与 `WEBHOOK_SECRET` 相同
|
||||
- **触发事件**: 「Pull Request」和「Status」
|
||||
|
||||
## 配置参考
|
||||
|
||||
### 核心配置
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `GITEA_API_URL` | Gitea API 地址 | 必填 |
|
||||
| `GITEA_ACCESS_TOKEN` | 代码审查令牌(需要读取和评论权限) | 必填 |
|
||||
| `GITEA_ADMIN_TOKEN` | Webhook 管理令牌(可选) | - |
|
||||
| `OPENAI_BASE_URL` | OpenAI API 基础地址 | `https://api.openai.com/v1` |
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | 必填 |
|
||||
| `OPENAI_MODEL` | 使用的模型 | `gpt-4o-mini` |
|
||||
| `PORT` | 服务端口 | `3000` |
|
||||
| `WEBHOOK_SECRET` | Webhook 签名验证密钥 | 必填 |
|
||||
|
||||
### 自定义提示词
|
||||
|
||||
| 变量 | 描述 |
|
||||
|------|------|
|
||||
| `CUSTOM_SUMMARY_PROMPT` | 自定义总结审查提示词 |
|
||||
| `CUSTOM_LINE_COMMENT_PROMPT` | 自定义行级评论提示词 |
|
||||
|
||||
### 管理后台
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `ADMIN_PASSWORD` | 后台登录密码 | `password` |
|
||||
| `JWT_SECRET` | JWT 签名密钥 | 自动生成 |
|
||||
|
||||
### 飞书集成
|
||||
|
||||
| 变量 | 描述 |
|
||||
|------|------|
|
||||
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook 地址 |
|
||||
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 密钥(可选) |
|
||||
|
||||
### Agent 审查引擎
|
||||
|
||||
设置 `REVIEW_ENGINE=agent` 启用多代理审查:
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `REVIEW_ENGINE` | 引擎模式(`legacy` 或 `agent`) | `legacy` |
|
||||
| `REVIEW_WORKDIR` | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
|
||||
| `REVIEW_MODEL_PLANNER` | 规划模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_SPECIALIST` | 专家模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_JUDGE` | 判断模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MAX_PARALLEL_RUNS` | 最大并发任务数 | `2` |
|
||||
| `REVIEW_MAX_FILES_PER_RUN` | 单次审查最大文件数 | `200` |
|
||||
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | 自动发布最小置信度 | `0.8` |
|
||||
| `REVIEW_ENABLE_HUMAN_GATE` | 启用人工审批 | `true` |
|
||||
|
||||
### 记忆与学习(实验性)
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `QDRANT_URL` | Qdrant 向量数据库地址 | - |
|
||||
| `ENABLE_MEMORY` | 启用记忆系统 | `false` |
|
||||
| `ENABLE_REFLECTION` | 启用自我批评 | `false` |
|
||||
| `ENABLE_DEBATE` | 启用多代理辩论 | `false` |
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 --env-file .env gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证
|
||||
- English: [README.md](./README.md)
|
||||
|
||||
0
docs/assets/.gitkeep
Normal file
BIN
docs/assets/page-config.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/assets/page-notifications.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/assets/page-repos.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/page-review-config.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
85
docs/configuration.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Configuration model
|
||||
|
||||
This project uses a DB-first runtime configuration model:
|
||||
|
||||
- `.env` contains only infrastructure-level bootstrap values.
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
|
||||
|
||||
## Environment variables (minimal)
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
|
||||
|
||||
Generate key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## First boot defaults
|
||||
|
||||
When database is empty:
|
||||
|
||||
- `JWT_SECRET` auto-generated
|
||||
- `WEBHOOK_SECRET` auto-generated
|
||||
- `ADMIN_PASSWORD` defaults to `password`
|
||||
|
||||
Change `ADMIN_PASSWORD` immediately after first login.
|
||||
|
||||
## Runtime groups in Admin UI
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access token
|
||||
- Admin token (optional)
|
||||
|
||||
## 2) Security
|
||||
|
||||
- Webhook secret (HMAC-SHA256 verification)
|
||||
- Admin password
|
||||
- JWT secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Agent runtime models:
|
||||
- `AGENT_MAIN_MODEL`: The main model name used by the agent runtime when no specific model is configured. Default is `gpt-4.1`.
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`: The default model name used by subagents when no specific model is declared in their definition or overridden during spawn. Default is `gpt-4.1-mini`.
|
||||
|
||||
## 4) Notification
|
||||
|
||||
- Feishu webhook and optional secret
|
||||
- WeCom (企业微信) webhook
|
||||
|
||||
## 5) Review
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage size classification and routing hints
|
||||
- Size thresholds (`small`/`medium`/`large`)
|
||||
- Execution modes (`skip`/`light`/`full`)
|
||||
- Token budgets and concurrency limits
|
||||
|
||||
> Size and mode are different layers:
|
||||
>
|
||||
> - `small/medium/large`: change-size classification
|
||||
> - `skip/light/full`: review execution depth
|
||||
|
||||
## Agent Definitions
|
||||
|
||||
Project agent definitions are stored as Markdown files with frontmatter in the repository:
|
||||
- Path: `.gitea-assistant/agents/*.md`
|
||||
|
||||
These files define the system prompts, metadata, and execution parameters for each agent.
|
||||
|
||||
## Tool Permissions
|
||||
|
||||
Tool permissions are controlled directly within each agent's definition file:
|
||||
- `tools`: An allow-list of tool names that the agent is permitted to call. An empty list grants no tools.
|
||||
- `disallowedTools`: A deny-list of tool names that the agent is explicitly forbidden from calling. This takes precedence over the allow-list.
|
||||
85
docs/configuration.zh-CN.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 配置参考
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 DB-first 运行时配置模型:
|
||||
|
||||
- `.env` 仅用于基础设施级引导参数
|
||||
- 运行时配置(Gitea、Provider、密钥、审查策略、通知)由管理后台维护并持久化到 SQLite
|
||||
|
||||
## 环境变量(最小集)
|
||||
|
||||
| 变量 | 必填 | 说明 | 默认值 |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | - |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
|
||||
|
||||
生成密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 首次启动默认值
|
||||
|
||||
当数据库为空时:
|
||||
|
||||
- `JWT_SECRET` 自动生成
|
||||
- `WEBHOOK_SECRET` 自动生成
|
||||
- `ADMIN_PASSWORD` 默认 `password`
|
||||
|
||||
首次登录后请立即修改管理员密码。
|
||||
|
||||
## 管理后台配置分组
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access Token
|
||||
- Admin Token(可选)
|
||||
|
||||
## 2) 安全
|
||||
|
||||
- Webhook Secret(HMAC-SHA256 验签)
|
||||
- Admin Password
|
||||
- JWT Secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Provider:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Agent 运行时模型:
|
||||
- `AGENT_MAIN_MODEL`:在没有更具体模型配置时,Agent 运行时使用的主模型名称。默认值为 `gpt-4.1`。
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`:当子代理(Subagent)未声明模型且 spawn 未覆盖时,使用的默认模型名称。默认值为 `gpt-4.1-mini`。
|
||||
|
||||
## 4) 通知
|
||||
|
||||
- Feishu Webhook 与可选签名密钥
|
||||
- WeCom(企业微信)Webhook
|
||||
|
||||
## 5) 审查
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 规模分类与路由提示
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
|
||||
> 规模与模式是两个层次:
|
||||
>
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
|
||||
## Agent 定义
|
||||
|
||||
项目的 Agent 定义以带有 Frontmatter 的 Markdown 文件形式存储在仓库中:
|
||||
- 路径:`.gitea-assistant/agents/*.md`
|
||||
|
||||
这些文件定义了每个 Agent 的系统提示词、元数据和执行参数。
|
||||
|
||||
## 工具权限
|
||||
|
||||
工具权限直接在每个 Agent 的定义文件中进行控制:
|
||||
- `tools`:允许该 Agent 调用的工具名称白名单。如果列表为空,则不授予任何工具权限。
|
||||
- `disallowedTools`:显式禁止该 Agent 调用的工具名称黑名单。黑名单的优先级高于白名单。
|
||||
58
docs/deployment.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Deployment
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` includes `gitea-assistant`.
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
The default ConfigMap sets `LOG_LEVEL=error` for production.
|
||||
|
||||
### 1) Create namespace and encryption secret
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) Deploy
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) Verify
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) Expose service (optional)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
58
docs/deployment.zh-CN.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 部署指南
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认包含 `gitea-assistant`。
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
默认 ConfigMap 已将生产日志级别设置为 `LOG_LEVEL=error`。
|
||||
|
||||
### 1) 创建命名空间与加密密钥
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) 部署
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
或逐个应用:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) 验证
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) 对外暴露(可选)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
617
docs/design/notification-service-refactoring.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# 通知服务抽象化重构方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台,代码高度耦合飞书特定的API实现。随着业务需求扩展,需要支持企业微信(WeCom)等其他通知渠道。
|
||||
|
||||
### 1.2 目标
|
||||
- 抽象通用通知服务接口,支持多平台扩展
|
||||
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
|
||||
- 统一通知调用入口,避免平台耦合与重复发送
|
||||
- 清晰的代码结构,便于后续添加新平台(如Slack、钉钉等)
|
||||
|
||||
### 1.3 非目标
|
||||
- 不修改通知的业务触发逻辑
|
||||
- 不改变现有的Gitea Webhook处理流程
|
||||
- 不引入外部通知服务SDK依赖(保持轻量)
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有架构分析
|
||||
|
||||
### 2.1 重构前实现(已下线)
|
||||
```
|
||||
src/
|
||||
├── services/feishu.ts # 飞书服务实现(156行)
|
||||
├── controllers/review.ts # 通知调用点
|
||||
├── config/config-schema.ts # 配置定义
|
||||
└── config/config-manager.ts # 配置管理
|
||||
```
|
||||
|
||||
### 2.2 关键代码特征
|
||||
- **强耦合**:`review.ts` 直接调用 `feishuService.sendXXXNotification()`
|
||||
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
|
||||
- **签名逻辑**:HMAC-SHA256(timestamp+"\n"+secret)
|
||||
- **配置单一**:仅支持一组飞书配置
|
||||
|
||||
### 2.3 通知场景
|
||||
| 场景 | 方法名 | 触发条件 |
|
||||
|------|--------|----------|
|
||||
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
|
||||
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
|
||||
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
|
||||
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
|
||||
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构设计
|
||||
|
||||
### 3.1 架构模式
|
||||
采用**策略模式(Strategy)** + **工厂模式(Factory)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Notification Service Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ INotification │ │ INotification │ │ INotification│ │
|
||||
│ │ Service │ │ Service │ │ Service │ │
|
||||
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
|
||||
│ │ Feishu │ │ WeCom │ │ Slack │ │
|
||||
│ │Service │ │Service │ │ Service │ │
|
||||
│ └───────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationFactory│
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationManager│ ← 统一入口,支持多服务
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心接口设计
|
||||
|
||||
#### 3.2.1 类型定义
|
||||
```typescript
|
||||
// types.ts
|
||||
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
|
||||
|
||||
export interface NotificationContext {
|
||||
// PR相关
|
||||
prTitle?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
|
||||
// Issue相关
|
||||
issueTitle?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
|
||||
// 用户相关
|
||||
actor?: string;
|
||||
assignees?: string[];
|
||||
reviewers?: string[];
|
||||
creator?: string;
|
||||
|
||||
// 仓库相关
|
||||
repository?: string;
|
||||
owner?: string;
|
||||
|
||||
// 时间戳
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: 'text' | 'markdown';
|
||||
title?: string;
|
||||
content: string;
|
||||
atUsers?: string[];
|
||||
url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 服务接口
|
||||
```typescript
|
||||
// INotificationService
|
||||
export interface INotificationService {
|
||||
readonly provider: NotificationProvider;
|
||||
|
||||
isEnabled(): boolean;
|
||||
sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 场景特定方法
|
||||
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 平台差异对照
|
||||
|
||||
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|
||||
|------|--------------|-----------------|-------|
|
||||
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
|
||||
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
|
||||
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
|
||||
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
|
||||
| **内容字段** | `content.text` | `text.content` | `text` |
|
||||
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细实现方案
|
||||
|
||||
### 4.1 目录结构
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── notification/
|
||||
│ │ ├── index.ts # 导出入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── base-notification-service.ts # 抽象基类
|
||||
│ │ ├── notification-factory.ts # 工厂
|
||||
│ │ ├── notification-manager.ts # 管理器
|
||||
│ │ └── providers/
|
||||
│ │ ├── feishu-notification-service.ts
|
||||
│ │ └── wecom-notification-service.ts
|
||||
│ └── notification-manager.ts # 运行时通知管理器入口
|
||||
```
|
||||
|
||||
### 4.2 基类实现
|
||||
|
||||
```typescript
|
||||
// base-notification-service.ts
|
||||
export abstract class BaseNotificationService implements INotificationService {
|
||||
abstract readonly provider: NotificationProvider;
|
||||
|
||||
constructor(protected config: NotificationServiceConfig) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && !!this.config.webhookUrl;
|
||||
}
|
||||
|
||||
abstract sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 通用模板方法
|
||||
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||
const message = this.buildIssueCreatedMessage(context);
|
||||
await this.sendMessage(message);
|
||||
}
|
||||
|
||||
// 子类实现消息构建
|
||||
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
|
||||
// ... 其他方法类似
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 飞书实现要点
|
||||
|
||||
```typescript
|
||||
// feishu-notification-service.ts
|
||||
export class FeishuNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'feishu' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 添加签名
|
||||
if (this.config.webhookSecret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
payload.timestamp = timestamp;
|
||||
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feishu notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSign(timestamp: string, secret: string): string {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 企业微信实现要点
|
||||
|
||||
```typescript
|
||||
// wecom-notification-service.ts
|
||||
export class WeComNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'wecom' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 企业微信使用 mentioned_list
|
||||
if (message.atUsers?.length) {
|
||||
payload.text.mentioned_list = message.atUsers.map(u =>
|
||||
u.toLowerCase() === 'all' ? '@all' : u
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WeCom notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 管理器实现
|
||||
|
||||
```typescript
|
||||
// notification-manager.ts
|
||||
export class NotificationManager {
|
||||
private services: INotificationService[] = [];
|
||||
|
||||
constructor(configs: NotificationServiceConfig[]) {
|
||||
this.services = configs
|
||||
.filter(c => c.enabled && c.webhookUrl)
|
||||
.map(c => NotificationFactory.createService(c));
|
||||
}
|
||||
|
||||
// 广播到所有服务
|
||||
async broadcast(
|
||||
operation: (service: INotificationService) => Promise<void>
|
||||
): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.services.map(async service => {
|
||||
try {
|
||||
await operation(service);
|
||||
} catch (error) {
|
||||
logger.error(`${service.provider} notification failed:`, error);
|
||||
throw error; // 重新抛出以便Promise.allSettled捕获
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 记录失败统计
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
async notifyIssueCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueClosedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueAssignedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置改造
|
||||
|
||||
### 5.1 新增配置字段
|
||||
|
||||
```typescript
|
||||
// config-schema.ts
|
||||
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
// ... 保留原有 ...
|
||||
|
||||
// 飞书配置(改造为可独立启用)
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// 企业微信配置(新增)
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '企业微信 Webhook 地址',
|
||||
description: '企业微信机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 5.2 配置组调整
|
||||
|
||||
```typescript
|
||||
// 将 'feishu' 组改为 'notification' 组
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
// ...
|
||||
{
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 调用层迁移
|
||||
|
||||
### 6.1 review.ts 改造
|
||||
|
||||
```typescript
|
||||
import { getNotificationManager } from '../services/notification-manager';
|
||||
|
||||
// PR事件处理
|
||||
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
|
||||
// ... 原有逻辑 ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
prTitle: pullRequest.title,
|
||||
prUrl: pullRequest.html_url,
|
||||
prNumber: pullRequest.number,
|
||||
reviewers: reviewerUsernames,
|
||||
repository: repo.name,
|
||||
owner: repo.owner.login,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
const notificationManager = getNotificationManager();
|
||||
|
||||
if (body.action === 'opened' && reviewerUsernames.length > 0) {
|
||||
await notificationManager.notifyPrCreated(context);
|
||||
}
|
||||
|
||||
if (body.action === 'review_requested' && body.requested_reviewer) {
|
||||
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
|
||||
await notificationManager.notifyPrReviewerAssigned(context);
|
||||
}
|
||||
|
||||
// ... 继续原有逻辑 ...
|
||||
}
|
||||
|
||||
// Issue事件处理
|
||||
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
||||
// ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.html_url,
|
||||
issueNumber: issue.number,
|
||||
creator: creatorUsername,
|
||||
assignees: assigneeUsernames,
|
||||
repository: repository.name,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
if (action === 'opened' && assigneeUsernames.length > 0) {
|
||||
await notificationManager.notifyIssueCreated(context);
|
||||
} else if (action === 'closed') {
|
||||
await notificationManager.notifyIssueClosed(context);
|
||||
} else if (action === 'assigned') {
|
||||
await notificationManager.notifyIssueAssigned(context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地决策(已执行)
|
||||
|
||||
### 7.1 旧飞书服务下线
|
||||
|
||||
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
|
||||
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`。
|
||||
- 通过单一通知入口避免重复发送与配置路径分裂问题。
|
||||
|
||||
### 7.2 运行时配置生效策略
|
||||
|
||||
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
|
||||
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
|
||||
|
||||
### 7.3 落地检查清单
|
||||
|
||||
- [x] 飞书与企业微信通过统一通知抽象发送
|
||||
- [x] 旧飞书服务文件已下线
|
||||
- [x] 控制器通知链路已去重
|
||||
- [x] 前端新增独立“通知管理”菜单与页面
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### 8.1 阶段划分
|
||||
|
||||
| 阶段 | 任务 | 文件 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
|
||||
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
|
||||
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
|
||||
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
|
||||
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
|
||||
| 6 | 调用层迁移 | `review.ts` | P1 |
|
||||
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
|
||||
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
|
||||
|
||||
### 8.2 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
|
||||
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
|
||||
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
```typescript
|
||||
// __tests__/notification.test.ts
|
||||
describe('NotificationService', () => {
|
||||
describe('FeishuNotificationService', () => {
|
||||
it('should generate correct signature', () => {
|
||||
// 测试签名算法
|
||||
});
|
||||
|
||||
it('should format message correctly', () => {
|
||||
// 测试消息格式转换
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeComNotificationService', () => {
|
||||
it('should use mentioned_list for @users', () => {
|
||||
// 测试@用户格式
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
it('should broadcast to all enabled services', async () => {
|
||||
// 测试广播逻辑
|
||||
});
|
||||
|
||||
it('should not fail if one service fails', async () => {
|
||||
// 测试容错
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- 配置真实飞书机器人测试消息发送
|
||||
- 配置企业微信机器人测试消息发送
|
||||
- 验证同时配置多个服务时的行为
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 飞书与企业微信API对比详情
|
||||
|
||||
#### 飞书消息格式
|
||||
```json
|
||||
{
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 企业微信消息格式
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "Hello World",
|
||||
"mentioned_list": ["wangqing", "@all"],
|
||||
"mentioned_mobile_list": ["13800001111"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 扩展指南
|
||||
|
||||
添加新通知平台步骤:
|
||||
|
||||
1. 在 `types.ts` 添加新的 `NotificationProvider` 类型
|
||||
2. 在 `providers/` 创建新的服务类,继承 `BaseNotificationService`
|
||||
3. 在 `notification-factory.ts` 添加创建逻辑
|
||||
4. 在 `config-schema.ts` 添加配置字段
|
||||
5. 在 Admin Dashboard 添加UI配置项
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**创建日期**: 2026-03-24
|
||||
**作者**: Sisyphus
|
||||
**状态**: 已实施(持续验证中)
|
||||
821
docs/design/pluggable-llm-providers.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# 技术设计文档:可插拔 LLM Provider 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-03-04
|
||||
> **相关 Issue**: N/A
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 设计原则](#0-设计原则)
|
||||
- [1. 目录结构](#1-目录结构新增改动部分)
|
||||
- [2. 数据库表结构](#2-数据库表结构sqlite-ddl)
|
||||
- [3. LLM Gateway 核心接口](#3-llm-gateway-核心-typescript-接口)
|
||||
- [4. Provider Adapter 差异映射](#4-四个-provider-adapter-核心差异映射)
|
||||
- [5. 后端 REST API 契约](#5-后端-rest-api-契约)
|
||||
- [6. 密钥安全设计](#6-密钥安全设计)
|
||||
- [7. 前端配置页设计](#7-前端配置页设计)
|
||||
- [8. 现有调用点改造清单](#8-现有调用点改造清单)
|
||||
- [9. 实施阶段建议](#9-实施阶段建议)
|
||||
- [10. 风险与缓解](#10-风险与缓解)
|
||||
|
||||
---
|
||||
|
||||
## 0. 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT`、`WEBHOOK_SECRET`、`DATABASE_PATH`) |
|
||||
| **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`(Responses API)、`anthropic`(Messages API)、`gemini`(generateContent API) |
|
||||
| **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` |
|
||||
| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB;主密钥通过环境变量 `ENCRYPTION_KEY` 传入(hex 编码,64 字符 = 32 字节),未设置则拒绝启动 |
|
||||
| **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 |
|
||||
|
||||
### 开源参考
|
||||
|
||||
| 借鉴点 | 参考项目 | 具体模式 |
|
||||
|---|---|---|
|
||||
| 接口先行 + provider registry | [Vercel AI SDK](https://github.com/vercel/ai) | `LanguageModelV3` spec-first adapter,版本化接口 |
|
||||
| Provider transformation 差异映射 | [LiteLLM](https://github.com/BerriAI/litellm) | 每个 provider 独立 `transformation.py`,标准化 error/usage |
|
||||
| Runtime factory + 多 provider 配置 | [LobeChat](https://github.com/lobehub/lobe-chat) | `createOpenAICompatibleRuntime` 工厂 + 动态 model list |
|
||||
| 配置驱动多 endpoint | [LibreChat](https://github.com/danny-avila/LibreChat) | `librechat.yaml` Custom Endpoints 模式 |
|
||||
| 能力声明/能力检测 | [Continue](https://github.com/continuedev/continue) | `supportsTools` / `supportsImages` 声明式 capability |
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构(新增/改动部分)
|
||||
|
||||
```
|
||||
src/
|
||||
├── db/
|
||||
│ ├── database.ts # bun:sqlite 初始化
|
||||
│ ├── migrations/
|
||||
│ │ └── 001_init.ts # 建表 DDL
|
||||
│ └── repositories/
|
||||
│ ├── provider-repo.ts # llm_providers CRUD
|
||||
│ ├── model-role-repo.ts # model_role_assignments CRUD
|
||||
│ ├── secret-repo.ts # 加密 read/write
|
||||
│ └── settings-repo.ts # system_settings KV
|
||||
│
|
||||
├── llm/
|
||||
│ ├── types.ts # 统一内部请求/响应类型
|
||||
│ ├── capabilities.ts # 能力声明枚举
|
||||
│ ├── errors.ts # LLM 层标准化错误
|
||||
│ ├── gateway.ts # LLM Gateway 入口(按 provider 路由)
|
||||
│ ├── tool-converter.ts # 工具定义 → 各 provider 格式转换
|
||||
│ └── providers/
|
||||
│ ├── base.ts # LLMProvider 抽象接口
|
||||
│ ├── openai-compatible.ts # 现有兼容格式 adapter
|
||||
│ ├── openai-responses.ts # OpenAI Responses API adapter
|
||||
│ ├── anthropic.ts # Anthropic Messages API adapter
|
||||
│ └── gemini.ts # Gemini generateContent adapter
|
||||
│
|
||||
├── crypto/
|
||||
│ └── secrets.ts # AES-256-GCM 加解密 + master key 管理
|
||||
│
|
||||
├── controllers/
|
||||
│ └── llm-config.ts # 新 REST API(替代 config.ts 中 LLM 部分)
|
||||
│
|
||||
└── config/
|
||||
├── config-manager.ts # 精简:只管非 LLM 配置(gitea/feishu/app/admin/review 非模型部分)
|
||||
└── config-schema.ts # 移除 openai group,LLM 配置全部走 DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库表结构(SQLite DDL)
|
||||
|
||||
### 2.1 ER 关系
|
||||
|
||||
```
|
||||
llm_providers 1 ←──── 1 llm_secrets (每个 provider 一个加密 key)
|
||||
llm_providers 1 ←──── N model_role_assignments (一个 provider 可服务多个角色)
|
||||
```
|
||||
|
||||
### 2.2 完整 DDL
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 表1: llm_providers — Provider 实例配置
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_providers (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
||||
name TEXT NOT NULL, -- 用户自定义显示名,如 "公司内部 OpenAI 代理"
|
||||
type TEXT NOT NULL CHECK (type IN (
|
||||
'openai_compatible', -- 现有兼容格式(自定义 baseUrl)
|
||||
'openai_responses', -- OpenAI 标准 Responses API
|
||||
'anthropic', -- Anthropic Messages API
|
||||
'gemini' -- Google Gemini generateContent
|
||||
)),
|
||||
base_url TEXT, -- 可选自定义 endpoint(openai_compatible 必填)
|
||||
default_model TEXT NOT NULL, -- 此 provider 的默认模型 ID
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1, -- 0=禁用, 1=启用
|
||||
extra_config TEXT DEFAULT '{}', -- JSON: provider 特有配置(如 api_version, project_id 等)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表2: llm_secrets — 加密存储的 API Key
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_secrets (
|
||||
provider_id TEXT PRIMARY KEY REFERENCES llm_providers(id) ON DELETE CASCADE,
|
||||
ciphertext BLOB NOT NULL, -- AES-256-GCM 密文
|
||||
iv BLOB NOT NULL, -- 12 bytes nonce
|
||||
auth_tag BLOB NOT NULL, -- 16 bytes GCM tag
|
||||
key_version INTEGER NOT NULL DEFAULT 1, -- 主密钥版本号(便于密钥轮换)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表3: model_role_assignments — 场景 → 模型映射
|
||||
-- ============================================================
|
||||
-- 每个业务场景(如 planner/specialist/judge)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge'
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表4: system_settings — 通用 KV 设置
|
||||
-- ============================================================
|
||||
-- 存放非 LLM 的业务配置(由 UI 直接写入 DB)
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
is_sensitive INTEGER NOT NULL DEFAULT 0, -- 1=加密存储(复用 crypto 模块)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_providers_type ON llm_providers(type);
|
||||
CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
|
||||
```
|
||||
|
||||
### 2.3 字段说明补充
|
||||
|
||||
| 表.字段 | 说明 |
|
||||
|---|---|
|
||||
| `llm_providers.type` | 决定使用哪个 adapter 实现 |
|
||||
| `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint |
|
||||
| `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 |
|
||||
| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 |
|
||||
| `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 |
|
||||
| `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`),GET API 返回 masked |
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM Gateway 核心 TypeScript 接口
|
||||
|
||||
### 3.1 统一消息与请求/响应类型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/types.ts ────────────────────────────────────────
|
||||
|
||||
/** 模型角色枚举 */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 provider 差异) */
|
||||
export interface LLMMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCallId?: string; // role=tool 时关联的 tool call ID
|
||||
toolCalls?: LLMToolCall[]; // role=assistant 时返回的 tool calls
|
||||
}
|
||||
|
||||
export interface LLMToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string; // JSON string
|
||||
}
|
||||
|
||||
/** 工具定义(内部通用格式,由 tool-converter.ts 转为各 provider 格式) */
|
||||
export interface LLMToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>; // JSON Schema
|
||||
}
|
||||
|
||||
/** 统一请求 */
|
||||
export interface LLMChatRequest {
|
||||
messages: LLMMessage[];
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json'; // 抽象 JSON mode
|
||||
tools?: LLMToolDefinition[];
|
||||
/** provider 透传配置(如 Anthropic thinking、Gemini safetySettings) */
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 统一响应 */
|
||||
export interface LLMChatResponse {
|
||||
content: string | null;
|
||||
toolCalls: LLMToolCall[];
|
||||
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error';
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
raw?: unknown; // 保留原始响应供调试
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 能力模型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/capabilities.ts ─────────────────────────────────
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** 是否支持 tool/function calling */
|
||||
supportsTools: boolean;
|
||||
/** 是否支持原生 JSON mode(vs 需要 prompt 指令 + 手动解析) */
|
||||
supportsJsonMode: boolean;
|
||||
/** 是否支持 SSE streaming */
|
||||
supportsStreaming: boolean;
|
||||
/** 是否支持 embedding 接口 */
|
||||
supportsEmbeddings: boolean;
|
||||
/** 是否支持图片/多模态输入 */
|
||||
supportsMultimodal: boolean;
|
||||
/** 最大输入 token 数(用于预校验,避免无效调用) */
|
||||
maxInputTokens?: number;
|
||||
}
|
||||
|
||||
/** 各 provider 默认能力声明 */
|
||||
export const DEFAULT_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
||||
openai_compatible: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: false, // 取决于具体模型
|
||||
},
|
||||
openai_responses: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
anthropic: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: false, // 无原生 JSON mode,需 prompt 指令
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: false,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
gemini: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true, // responseMimeType: 'application/json'
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 Provider 抽象接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/providers/base.ts ───────────────────────────────
|
||||
|
||||
import type { ProviderCapabilities } from '../capabilities';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../types';
|
||||
|
||||
export interface LLMProvider {
|
||||
/** Provider 类型标识 */
|
||||
readonly type: string;
|
||||
|
||||
/** 能力声明 */
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
/**
|
||||
* 核心调用方法。Gateway 只调用此方法。
|
||||
* 各 adapter 负责:
|
||||
* 1. 将 LLMChatRequest 转为 provider 原生格式
|
||||
* 2. 发 HTTP / SDK 调用
|
||||
* 3. 将原生响应转为 LLMChatResponse
|
||||
*/
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
|
||||
/** 可选:嵌入接口 */
|
||||
embed?(texts: string[]): Promise<number[][]>;
|
||||
}
|
||||
|
||||
/** Provider 工厂函数签名:从 DB 配置 + 解密后 apiKey 创建实例 */
|
||||
export type ProviderFactory = (config: {
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
extraConfig: Record<string, unknown>;
|
||||
}) => LLMProvider;
|
||||
```
|
||||
|
||||
### 3.4 Gateway 入口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/gateway.ts ──────────────────────────────────────
|
||||
|
||||
import type { ModelRole, LLMChatRequest, LLMChatResponse } from './types';
|
||||
import type { LLMProvider } from './providers/base';
|
||||
|
||||
/**
|
||||
* LLM Gateway — 业务层唯一入口
|
||||
*
|
||||
* 职责:
|
||||
* 1. 根据 role 查询 model_role_assignments → provider_id + model
|
||||
* 2. 从 provider 缓存获取(或按需创建)LLMProvider 实例
|
||||
* 3. 调用 provider.chat() 并返回统一响应
|
||||
* 4. 如果 provider 配置变更(UI 保存时),invalidate 缓存
|
||||
*/
|
||||
export class LLMGateway {
|
||||
/** provider 实例缓存(provider_id → LLMProvider) */
|
||||
private cache = new Map<string, LLMProvider>();
|
||||
|
||||
/**
|
||||
* 按业务角色调用 LLM
|
||||
* @param role 业务角色(planner/specialist/judge)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 用指定 provider 直接调用(连通性测试用)
|
||||
*/
|
||||
async chatDirect(
|
||||
providerId: string,
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/** 配置变更时清除单个 provider 缓存 */
|
||||
invalidateProvider(providerId: string): void;
|
||||
|
||||
/** 清除全部缓存(全局配置变更时) */
|
||||
invalidateAll(): void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 四个 Provider Adapter 核心差异映射
|
||||
|
||||
### 4.1 总览对照表
|
||||
|
||||
| 特性 | openai_compatible | openai_responses | anthropic | gemini |
|
||||
|---|---|---|---|---|
|
||||
| **SDK/HTTP** | `openai` npm (`chat.completions`) | `openai` npm (`responses.create`) | `@anthropic-ai/sdk` | `@google/generative-ai` 或 REST |
|
||||
| **系统指令** | `messages[0].role='system'` | `instructions` 参数 | `system` 顶层参数 | `systemInstruction` 参数 |
|
||||
| **JSON mode** | `response_format: {type:'json_object'}` | `text.format: {type:'json_object'}` | 无原生支持 → prompt 指令 + `JSON.parse` | `responseMimeType: 'application/json'` + `responseSchema` |
|
||||
| **工具调用请求** | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].name` + `input_schema` | `tools[].functionDeclarations[].{name,description,parameters}` |
|
||||
| **工具结果返回** | `role: 'tool'` + `tool_call_id` | `type: 'function_call_output'` + `call_id` | `role: 'user'` + `content: [{type:'tool_result', tool_use_id}]` | `role: 'function'` + `parts: [{functionResponse}]` |
|
||||
| **finish_reason** | `stop` / `tool_calls` / `length` | `stop` / `tool_calls` / ... | `end_turn` / `tool_use` / `max_tokens` | `STOP` / `FUNCTION_CALL` / `MAX_TOKENS` |
|
||||
| **Token 用量** | `usage.{prompt,completion}_tokens` | `usage.{input,output}_tokens` | `usage.{input,output}_tokens` | `usageMetadata.{prompt,candidates}TokenCount` |
|
||||
|
||||
### 4.2 各 Adapter 核心转换逻辑
|
||||
|
||||
#### 4.2.1 openai_compatible(现有兼容格式)
|
||||
|
||||
```typescript
|
||||
// 请求转换:几乎直通(这就是现有代码逻辑的抽象)
|
||||
// - LLMMessage → OpenAI ChatCompletionMessage (直接映射)
|
||||
// - responseFormat='json' → { type: 'json_object' }
|
||||
// - tools → tools[].function (直接映射)
|
||||
//
|
||||
// 响应转换:
|
||||
// - choices[0].message.content → content
|
||||
// - choices[0].message.tool_calls → toolCalls
|
||||
// - choices[0].finish_reason → finishReason (直接映射)
|
||||
// - usage.{prompt,completion}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.2 openai_responses(Responses API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 instructions 参数
|
||||
// - 非 system messages 转为 input items
|
||||
// - responseFormat='json' → text: { format: { type: 'json_object' } }
|
||||
// - tools → tools[].function
|
||||
//
|
||||
// 响应转换:
|
||||
// - output items 中 type='message' → content
|
||||
// - output items 中 type='function_call' → toolCalls
|
||||
// - status → finishReason 映射
|
||||
// - usage.{input,output}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.3 anthropic(Messages API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 system 顶层参数
|
||||
// - 非 system messages → messages(role 直接映射)
|
||||
// - responseFormat='json' → 无原生支持,在 system prompt 末尾追加:
|
||||
// "You MUST respond with valid JSON only. No other text."
|
||||
// - tools → tools[].{ name, description, input_schema }
|
||||
// - tool results → role='user', content: [{ type: 'tool_result', tool_use_id, content }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - content blocks: type='text' → content
|
||||
// - content blocks: type='tool_use' → toolCalls (id, name, JSON.stringify(input))
|
||||
// - stop_reason: 'end_turn' → 'stop', 'tool_use' → 'tool_calls', 'max_tokens' → 'length'
|
||||
// - usage.{input,output}_tokens → usage
|
||||
//
|
||||
// JSON mode 容错:
|
||||
// - JSON.parse(content) 失败时,尝试正则提取 ```json...``` 块
|
||||
```
|
||||
|
||||
#### 4.2.4 gemini(generateContent API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 systemInstruction: { parts: [{ text }] }
|
||||
// - messages → contents[].{ role: 'user'|'model', parts: [{ text }] }
|
||||
// 注意:Gemini 用 'model' 而非 'assistant'
|
||||
// - responseFormat='json' → generationConfig: {
|
||||
// responseMimeType: 'application/json',
|
||||
// responseSchema: <如果有的话>
|
||||
// }
|
||||
// - tools → tools: [{ functionDeclarations: [...] }]
|
||||
// - tool results → role: 'function', parts: [{ functionResponse: { name, response } }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - candidates[0].content.parts: type='text' → content
|
||||
// - candidates[0].content.parts: functionCall → toolCalls
|
||||
// - candidates[0].finishReason: 'STOP' → 'stop', 'FUNCTION_CALL' → 'tool_calls', 'MAX_TOKENS' → 'length'
|
||||
// - usageMetadata.{promptTokenCount, candidatesTokenCount} → usage
|
||||
```
|
||||
|
||||
### 4.3 tool-converter.ts 接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/tool-converter.ts ───────────────────────────────
|
||||
|
||||
import type { LLMToolDefinition } from './types';
|
||||
|
||||
/**
|
||||
* 将内部通用 LLMToolDefinition 转为各 provider 原生格式。
|
||||
* 由各 adapter 在 chat() 中调用。
|
||||
*/
|
||||
|
||||
/** → OpenAI / OpenAI Compatible 格式 */
|
||||
export function toOpenAITools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Anthropic 格式 */
|
||||
export function toAnthropicTools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Gemini functionDeclarations 格式 */
|
||||
export function toGeminiTools(tools: LLMToolDefinition[]): object[];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端 REST API 契约
|
||||
|
||||
所有新 API 挂在 `/admin/api/llm/` 下,复用现有 JWT 鉴权中间件。
|
||||
|
||||
### 5.1 Provider 管理
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/providers` | 列出所有 provider(含 `hasKey` 布尔,不含明文 key) |
|
||||
| `POST` | `/admin/api/llm/providers` | 创建 provider + 设置 API Key |
|
||||
| `GET` | `/admin/api/llm/providers/:id` | 获取单个详情 |
|
||||
| `PUT` | `/admin/api/llm/providers/:id` | 更新(name/base_url/default_model/extra_config/is_enabled) |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id` | 删除(级联删 secret + role assignments) |
|
||||
|
||||
### 5.2 API Key(仅 set/clear,不回显)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `PUT` | `/admin/api/llm/providers/:id/key` | 设置/更新 API Key |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id/key` | 清除 API Key |
|
||||
|
||||
### 5.3 角色 → 模型映射
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/roles` | 列出所有角色及当前绑定 |
|
||||
| `PUT` | `/admin/api/llm/roles/:role` | 设置角色绑定 |
|
||||
|
||||
### 5.4 连通性测试
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `POST` | `/admin/api/llm/providers/:id/test` | 发送简单 prompt 验证 provider 可达 |
|
||||
|
||||
### 5.5 通用设置(非 LLM)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/settings` | 列出所有(sensitive 字段 masked) |
|
||||
| `PUT` | `/admin/api/settings` | 批量更新 |
|
||||
|
||||
### 5.6 请求/响应示例
|
||||
|
||||
#### 创建 Provider
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers
|
||||
// Request:
|
||||
{
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"apiKey": "sk-ant-xxxx",
|
||||
"extraConfig": {}
|
||||
}
|
||||
|
||||
// Response 201:
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"isEnabled": true,
|
||||
"hasKey": true,
|
||||
"extraConfig": {},
|
||||
"createdAt": "2026-03-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 设置角色绑定
|
||||
|
||||
```jsonc
|
||||
// PUT /admin/api/llm/roles/specialist
|
||||
// Request:
|
||||
{
|
||||
"providerId": "a1b2c3d4",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
// Response 200:
|
||||
{
|
||||
"role": "specialist",
|
||||
"providerId": "a1b2c3d4",
|
||||
"providerName": "Anthropic Claude",
|
||||
"providerType": "anthropic",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
```
|
||||
|
||||
#### 连通性测试
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers/a1b2c3d4/test
|
||||
// Response 200:
|
||||
{
|
||||
"success": true,
|
||||
"latencyMs": 823,
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"message": "Hello! I'm Claude, an AI assistant."
|
||||
}
|
||||
|
||||
// Response 200 (失败):
|
||||
{
|
||||
"success": false,
|
||||
"latencyMs": 5012,
|
||||
"error": "401 Unauthorized: Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 密钥安全设计
|
||||
|
||||
### 6.1 Master Key 管理
|
||||
|
||||
```
|
||||
启动流程:
|
||||
1. 读取环境变量 ENCRYPTION_KEY(hex 编码,64 字符)
|
||||
├── 未设置或为空 → 抛出错误,拒绝启动
|
||||
├── 长度不正确 → 抛出错误,提示需要 64 个十六进制字符
|
||||
└── 正确 → 解码为 32 字节 Buffer
|
||||
2. 主密钥常驻内存(进程生命周期)
|
||||
3. 绝对不写入日志、不暴露给 API
|
||||
```
|
||||
|
||||
### 6.2 加密流程(写 API Key)
|
||||
|
||||
```
|
||||
输入: plaintext apiKey (string)
|
||||
1. 生成 12 bytes IV: crypto.randomFillSync(new Uint8Array(12))
|
||||
2. 创建 cipher: crypto.createCipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 加密: ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
|
||||
4. 获取 auth tag: authTag = cipher.getAuthTag() // 16 bytes
|
||||
5. 写 DB: INSERT INTO llm_secrets (provider_id, ciphertext, iv, auth_tag, key_version)
|
||||
```
|
||||
|
||||
### 6.3 解密流程(Gateway 需要调 provider)
|
||||
|
||||
```
|
||||
输入: provider_id
|
||||
1. 从 DB 读取: { ciphertext, iv, auth_tag, key_version }
|
||||
2. 创建 decipher: crypto.createDecipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 设置 auth tag: decipher.setAuthTag(authTag)
|
||||
4. 解密: plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
5. 返回明文 API Key → 传给 provider factory
|
||||
6. 解密后的 key 随 provider 实例缓存在 Gateway.cache 中
|
||||
```
|
||||
|
||||
### 6.4 密钥轮换
|
||||
|
||||
```
|
||||
场景: 管理员更换 ENCRYPTION_KEY
|
||||
1. 启动时读取新的 ENCRYPTION_KEY 环境变量
|
||||
2. 查询所有 llm_secrets WHERE key_version < current_version
|
||||
3. 逐条: 用旧 key 解密 → 用新 key 重加密 → 更新 key_version
|
||||
4. 如果旧 key 不可用(环境变量缺失)→ 启动报错,要求重新设置所有 API Key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端配置页设计
|
||||
|
||||
### 7.1 页面结构
|
||||
|
||||
```
|
||||
Settings 页面
|
||||
├── 🔌 LLM Providers(Tab 或独立 Card)
|
||||
│ │
|
||||
│ ├── Provider 列表表格
|
||||
│ │ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ │ 名称 │ 类型 │ 默认模型 │ Key │ 状态 │ 操作 │
|
||||
│ │ ├───────────────────┼───────────────┼───────────────┼─────┼────────┼──────┤
|
||||
│ │ │ 公司 OpenAI 代理 │ OpenAI Compat │ gpt-4o-mini │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Anthropic Claude │ Anthropic │ claude-sonnet │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Google Gemini │ Gemini │ gemini-2.5-pro│ ○ │ [开关] │ [⋮] │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘
|
||||
│ │ + 添加 Provider 按钮
|
||||
│ │
|
||||
│ ├── 添加/编辑 Provider 对话框
|
||||
│ │ ├── 名称 (text input)
|
||||
│ │ ├── 类型 (select dropdown)
|
||||
│ │ │ ├── OpenAI Compatible — 兼容 OpenAI 接口的第三方服务
|
||||
│ │ │ ├── OpenAI Responses — OpenAI 官方 Responses API
|
||||
│ │ │ ├── Anthropic — Anthropic Messages API
|
||||
│ │ │ └── Gemini — Google Gemini API
|
||||
│ │ ├── Base URL (text, 条件显示:openai_compatible 必填, 其他可选)
|
||||
│ │ ├── 默认模型 (text + autocomplete suggestions)
|
||||
│ │ ├── API Key (password input, 已有时显示 ••••••••)
|
||||
│ │ ├── ▸ 高级配置 (collapsible, JSON key-value editor)
|
||||
│ │ └── [测试连接] [保存] [取消]
|
||||
│ │
|
||||
│ └── 🧩 角色分配与分级审查映射 区域
|
||||
│ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ 角色/阶段 │ Provider 下拉 │ 模型 ID │
|
||||
│ ├──────────────┼──────────────────────┼──────────────────────┤
|
||||
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
|
||||
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
|
||||
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
|
||||
│ ├── Agent 分级审查参数:small/medium 阈值、token budget、triage 开关
|
||||
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
|
||||
```
|
||||
|
||||
### 7.2 交互规则
|
||||
|
||||
| 交互 | 行为 |
|
||||
|---|---|
|
||||
| **添加 Provider** | 弹出对话框;类型选择后动态显示/隐藏 `base_url` 字段 |
|
||||
| **API Key 输入** | 已有 key 时展示 `••••••••`(readonly 占位);清空内容后保存 = 删除 key;输入新值 = 替换(调用 `PUT /key`);未修改 = 不发请求 |
|
||||
| **测试连接** | 点击后调 `POST /providers/:id/test`;显示 spinner → 成功绿色 toast(延迟+模型)/ 失败红色 toast(错误信息) |
|
||||
| **角色分配下拉** | 仅显示 `is_enabled=true` 且 `hasKey=true` 的 provider;选择后自动填充该 provider 的 `default_model`(用户可修改) |
|
||||
| **禁用 Provider** | 如果有角色绑定到此 provider → 弹确认对话框:"此 Provider 正被以下角色使用:[...],禁用后这些角色将无法调用 LLM。确定禁用?" |
|
||||
| **删除 Provider** | 同上,级联影响提示更强烈 |
|
||||
| **模型建议** | 根据 provider type 显示常见模型建议列表(硬编码在前端,仅作参考,不限制输入) |
|
||||
|
||||
### 7.3 模型建议列表(前端硬编码参考)
|
||||
|
||||
```typescript
|
||||
const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 现有调用点改造清单
|
||||
|
||||
### 8.1 后端代码改造
|
||||
|
||||
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
|
||||
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
|
||||
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent(任务化分级编排:skip/light/full) | Agent 编排 |
|
||||
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway`;`reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
|
||||
| 5 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 6 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 7 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 8 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
|
||||
### 8.2 前端代码改造
|
||||
|
||||
| # | 文件 | 改造内容 |
|
||||
|---|---|---|
|
||||
| 1 | `frontend/src/services/configService.ts` | 新增 `llmProviderService.ts`(Provider CRUD + Key 管理 + Role 管理 + Test) |
|
||||
| 2 | `frontend/src/components/ConfigManager.tsx` | 添加 "LLM Providers" Tab/Card,引入新组件 |
|
||||
| 3 | 新增 | `frontend/src/components/llm/ProviderList.tsx` — Provider 列表表格 |
|
||||
| 4 | 新增 | `frontend/src/components/llm/ProviderDialog.tsx` — 添加/编辑对话框 |
|
||||
| 5 | 新增 | `frontend/src/components/llm/RoleAssignment.tsx` — 角色分配面板 |
|
||||
|
||||
### 8.3 配置层改造
|
||||
|
||||
| 变更 | 说明 |
|
||||
|---|---|
|
||||
| `config-manager.ts` | 精简为只管非 LLM 配置;数据源统一为 `system_settings` 表 |
|
||||
| `config-schema.ts` | 移除 `openai` group 及其字段;保留 gitea/feishu/app/admin/review(非模型)字段 |
|
||||
| `controllers/config.ts` | LLM 相关接口迁到 `controllers/llm-config.ts`;通用配置接口改读写 DB |
|
||||
| `.env.example` | 移除 `OPENAI_*` 和 `REVIEW_MODEL_*`;仅保留启动参数 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施阶段建议
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 估时 |
|
||||
|---|---|---|---|
|
||||
| **Phase 1: 基础设施** | DB 层 (`bun:sqlite` 初始化 + DDL) + crypto 模块 | 无 | 1d |
|
||||
| **Phase 2: LLM 抽象层** | `src/llm/` 全部(types + capabilities + errors + gateway + 4 adapters + tool-converter) | Phase 1 | 2d |
|
||||
| **Phase 3: 后端 API + 调用点替换** | `controllers/llm-config.ts` + 替换 11 个现有 OpenAI 调用点 + 测试 | Phase 2 | 1.5d |
|
||||
| **Phase 4: 前端改造** | Provider 管理 + 角色分配 + 连接测试 UI + 通用设置切 DB | Phase 3 | 1.5d |
|
||||
| **Phase 5: 清理与验收** | 删除旧代码 + 更新文档 + E2E 测试 + `.env.example` 精简 | Phase 4 | 0.5d |
|
||||
|
||||
**总计约 6.5 人天。**
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
```
|
||||
Day 1: DB + crypto 就绪,配置写入链路打通
|
||||
Day 3: LLM Gateway 可用,4 个 adapter 通过单元测试
|
||||
Day 4.5: 后端 API 完成,所有调用点已替换,`bun test` 全绿
|
||||
Day 6: 前端配置页可用,可通过 UI 添加/测试 Provider
|
||||
Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) |
|
||||
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 |
|
||||
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) |
|
||||
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 |
|
||||
| **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 |
|
||||
| **配置热更新** | UI 修改 provider 配置后,正在进行的审查仍用旧配置 | Gateway 缓存按 provider_id 粒度 invalidate;正在执行的请求不受影响(用的是已创建的实例),下次请求用新实例 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 新增依赖
|
||||
|
||||
```jsonc
|
||||
// package.json 新增
|
||||
{
|
||||
"dependencies": {
|
||||
// bun:sqlite 是 Bun 内置,无需安装
|
||||
"@anthropic-ai/sdk": "^0.39.0", // Anthropic adapter
|
||||
"@google/generative-ai": "^0.24.0" // Gemini adapter
|
||||
// "openai" 已存在: "^4.87.3" // OpenAI compatible + Responses adapter
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B: 环境变量精简
|
||||
|
||||
```bash
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
# 以下配置已迁入数据库,通过 Web UI 管理:
|
||||
# - LLM Provider 配置(API Key / Base URL / Model)
|
||||
# - Gitea 配置(API URL / Token)
|
||||
# - 飞书配置(Webhook URL / Secret)
|
||||
# - Review 引擎配置
|
||||
```
|
||||
154
docs/design/ui-theme-language.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# UI Theme Language(亮/暗双主题统一规范)
|
||||
|
||||
## 目标
|
||||
|
||||
- 保证浅色/深色主题视觉一致、可读性稳定。
|
||||
- 避免组件直接写死颜色,防止后续开发样式漂移。
|
||||
- 让新增页面默认遵循同一套语义化设计语言。
|
||||
|
||||
## 三层设计语言模型
|
||||
|
||||
1. **Primitive(原子值)**:HSL 基础值,仅在全局 token 定义处出现。
|
||||
2. **Semantic(语义 token)**:`background`、`foreground`、`success`、`danger` 等,按语义命名。
|
||||
3. **Component(组件 token)**:组件只能消费语义 token,不允许跨层引用原子值。
|
||||
|
||||
## 当前项目的主题基线
|
||||
|
||||
主题定义文件:`frontend/src/index.css`
|
||||
|
||||
- 基础语义:`background`、`foreground`、`card`、`muted`、`border`、`ring`
|
||||
- 状态语义:`success`、`warning`、`danger`、`info`
|
||||
- 补充语义:`surface-muted`、`surface-elevated`、`surface-overlay`、`text-subtle`、`text-soft`、`border-soft`
|
||||
|
||||
Tailwind 语义映射:`frontend/tailwind.config.js`
|
||||
|
||||
- 已将语义 token 映射为可直接使用的 class(如 `bg-success/10`、`text-danger`、`border-info/20`)。
|
||||
|
||||
### 主色方案(当前)
|
||||
|
||||
- 主色选择:**Cobalt Blue(钴蓝)**,兼顾 light/dark 的对比度与品牌辨识度。
|
||||
- Light:`--primary: 224 76% 52%`,`--primary-foreground: 0 0% 100%`
|
||||
- Dark:`--primary: 224 88% 68%`,`--primary-foreground: 224 40% 12%`
|
||||
- 焦点环:`--ring` 与 `--primary` 保持同色,确保交互一致性。
|
||||
- 设计理由:亮色下避免过“脏”或偏绿感;暗色下提升明度保证可见性,同时用深色前景保证主按钮文字对比。
|
||||
|
||||
## 可选整套主题色方案(社区开源复用)
|
||||
|
||||
> 要求:切换的是**整套语义 token**,不是只改 `primary`。
|
||||
|
||||
当前支持四套(统一冷调科技风):
|
||||
|
||||
1. `cobalt`(默认)
|
||||
- 本项目当前默认冷色科技风(自定义)。
|
||||
2. `zinc`
|
||||
- 来源:shadcn/ui themes(MIT)
|
||||
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
|
||||
3. `nord`
|
||||
- 来源:Nord(MIT)
|
||||
- 参考:<https://github.com/nordtheme/nord>
|
||||
4. `tokyo-night`
|
||||
- 来源:Tokyo Night(Apache-2.0)
|
||||
- 参考:<https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
实现方式:
|
||||
|
||||
- `cobalt` 作为内置基础主题,直接由 `:root` / `.dark` 提供默认 token。
|
||||
- 其余方案(`zinc|nord|tokyo-night`)通过 `data-palette` 覆盖:
|
||||
- `:root[data-palette='*']` 覆盖浅色 token
|
||||
- `.dark[data-palette='*']` 覆盖暗色 token
|
||||
- 在根节点写入 `data-palette`(`cobalt|zinc|nord|tokyo-night`)。
|
||||
- 组件侧不改业务 class,继续消费语义 token。
|
||||
|
||||
## 页面风格骨架(社区方案落地)
|
||||
|
||||
> 目标:即使切换配色,页面结构、密度、层级、动效仍保持统一。
|
||||
|
||||
本项目采用了三类社区成熟范式,并映射到本仓库 utility:
|
||||
|
||||
1. **4px 节奏与密度系统(shadcn/仪表盘实践)**
|
||||
- 基础节奏按 4px 递进,主内容区使用 `theme-page-content`(统一宽度 + 留白节奏)。
|
||||
- 卡片内部与卡片间距默认采用 `p-6 / gap-6` 级别,避免页面“块状松散或拥挤”。
|
||||
2. **三层深度系统(卡片/悬浮/遮罩)**
|
||||
- 统一卡片外观:`theme-card-shell` + `theme-card-header` + `theme-card-content`。
|
||||
- 交互抬升统一:`theme-interactive-elevate`(轻微位移 + 阴影,不做夸张动效)。
|
||||
- 页面壳层统一:`theme-shell-gradient` + `theme-sticky-bar`。
|
||||
3. **可控动效系统(Linear/Vercel 风格)**
|
||||
- Hover/按钮反馈优先短时平滑动效,避免大幅动画导致“廉价感”。
|
||||
- 表单输入统一 `theme-input-surface`,状态条与统计胶囊统一 `theme-control-pill`。
|
||||
|
||||
参考来源:
|
||||
|
||||
- shadcn/ui themes 与组件风格实践(MIT):<https://github.com/shadcn-ui/ui>
|
||||
- Vercel Dashboard 设计迭代思路:<https://vercel.com/changelog/dashboard-navigation-redesign-rollout>
|
||||
- Nord / Tokyo Night 社区配色体系:
|
||||
- <https://github.com/nordtheme/nord>
|
||||
- <https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
## 强制规则(必须遵守)
|
||||
|
||||
1. **禁止在业务 TSX 中使用硬编码暗色类**:如 `bg-zinc-*`、`text-zinc-*`、`border-white/10`(历史 UI 基础组件逐步迁移,不作为新增业务代码例外)。
|
||||
2. **禁止在组件内写死颜色值**:如 `rgba(...)`、`#xxxxxx`、`rgb(...)`。
|
||||
3. **状态色统一语义化**:成功/警告/错误/信息统一用 `success|warning|danger|info`。
|
||||
4. **弹窗/卡片/表格优先使用语义表面色**:`card`、`muted`、`popover`、`background`。
|
||||
5. **交互阴影统一工具类**:`theme-glow-primary|success|warning|danger`。
|
||||
6. **普通 hover 反馈禁止用主色背景**:非主操作控件统一使用 `hover:bg-accent*` 或 `hover:bg-muted*`,避免亮色主题出现重色块。
|
||||
|
||||
## 推荐 class 使用方式
|
||||
|
||||
- 文字层级:`text-foreground` / `text-muted-foreground`
|
||||
- 面板层级:`bg-card` / `bg-muted/50` / `bg-popover`
|
||||
- 边框层级:`border-border` / `theme-border-soft`
|
||||
- 状态展示:`text-success`、`bg-danger/10`、`border-warning/30`
|
||||
- 普通交互 hover:`hover:bg-accent/60`、`hover:bg-accent`、`hover:bg-muted/60`
|
||||
- 主操作 hover:仅主按钮可用 `hover:bg-primary/90`
|
||||
- 顶部吸附操作栏:`theme-sticky-bar`
|
||||
- 页面骨架:`theme-page-frame` / `theme-page-actions` / `theme-page-content`
|
||||
- 卡片骨架:`theme-card-shell` / `theme-card-header` / `theme-card-content`
|
||||
- 弹窗骨架:`theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer`
|
||||
- 错误态容器:`theme-error-panel`
|
||||
- 模态遮罩:`theme-surface-overlay`
|
||||
|
||||
## 页面级统一约束(防止布局风格漂移)
|
||||
|
||||
1. 页面容器优先使用 `theme-page-frame`,避免每个页面自行定义高度和底部间距。
|
||||
2. 顶部操作区统一使用 `theme-sticky-bar + theme-page-actions`,避免按钮栏视觉断层。
|
||||
3. 主内容区统一使用 `theme-page-content`,确保横向节奏和留白一致。
|
||||
4. 标准业务卡片统一使用 `theme-card-shell/header/content`,避免同类卡片出现不同边框/背景层级。
|
||||
5. 错误提示统一使用 `theme-error-panel`,保持状态反馈视觉语言一致。
|
||||
|
||||
## destructive 与 danger 的约定
|
||||
|
||||
- `destructive`:保留给 shadcn 组件内置 destructive 变体语义。
|
||||
- `danger`:业务状态语义(报错、失败、风险提示)统一使用。
|
||||
- 新业务组件优先使用 `danger`,避免 `destructive/danger` 混用造成漂移。
|
||||
|
||||
## 新功能开发检查清单
|
||||
|
||||
- [ ] 页面在 light/dark 下均可读(文本、边框、状态色有对比度)
|
||||
- [ ] 无 `zinc/white` 等暗色硬编码 class
|
||||
- [ ] 无内联 `style` 颜色值
|
||||
- [ ] 状态色全部使用语义 token
|
||||
- [ ] 组件未绕过语义层直接访问原子颜色
|
||||
- [ ] `bun run ui:visual` 通过(light/dark 关键页面视觉回归)
|
||||
|
||||
## 视觉基线截图回归(Playwright)
|
||||
|
||||
- 生成/更新基线:`bun run ui:visual:update`
|
||||
- 校验基线一致性:`bun run ui:visual`
|
||||
- 轻量 UI 全链路:`bun run ui:regression && bun run ui:visual`
|
||||
|
||||
约定:
|
||||
|
||||
1. PR 默认运行 `ui:visual`,出现 diff 必须人工确认是“预期视觉变更”。
|
||||
2. 只有在确认设计变更成立时,才执行 `ui:visual:update` 更新基线并提交快照。
|
||||
3. 不允许在未更新设计规范的情况下大量更新视觉基线,避免把漂移“固化为正确”。
|
||||
4. 基线快照以 Linux CI 环境为准(当前为 `*-linux.png`),避免跨系统更新导致快照噪声。
|
||||
|
||||
## 迁移策略
|
||||
|
||||
当新增模块时,按以下顺序处理:
|
||||
|
||||
1. 先补充语义 token(如确有新语义,而不是新颜色)。
|
||||
2. 在 Tailwind 映射语义 token。
|
||||
3. 在组件中只消费语义 class。
|
||||
4. 最后做 light/dark 视觉回归。
|
||||
69
docs/getting-started.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- A reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
`bun install` at repository root installs frontend dependencies via `postinstall`.
|
||||
|
||||
If lifecycle scripts are disabled:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## Minimal environment
|
||||
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
## First login
|
||||
|
||||
- Open `http://your-server:5174`
|
||||
- Default admin password is `password` on first boot
|
||||
- Change admin password immediately after login
|
||||
|
||||
## Webhook setup
|
||||
|
||||
### Option A: Admin UI (recommended)
|
||||
|
||||
In repository list, click enable to auto-provision webhook.
|
||||
|
||||
### Option B: Manual
|
||||
|
||||
In Gitea repository settings:
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content Type: `application/json`
|
||||
- Secret: same value as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
## Health endpoint
|
||||
|
||||
Use `/api/health` to check service status.
|
||||
69
docs/getting-started.zh-CN.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商凭证
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
|
||||
如果你的环境禁用了生命周期脚本:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## 最小环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# 或
|
||||
bun run start
|
||||
```
|
||||
|
||||
## 首次登录
|
||||
|
||||
- 访问 `http://your-server:5174`
|
||||
- 首次启动默认管理员密码为 `password`
|
||||
- 登录后请立即修改管理员密码
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
### 方式 A:管理后台(推荐)
|
||||
|
||||
在仓库列表点击启用按钮,由系统自动配置 webhook。
|
||||
|
||||
### 方式 B:手动配置
|
||||
|
||||
在 Gitea 仓库设置中配置:
|
||||
|
||||
- URL:`http://your-server:5174/webhook/gitea`
|
||||
- Content Type:`application/json`
|
||||
- Secret:与管理后台中的 Webhook Secret 保持一致
|
||||
- 事件:Pull Request + Status
|
||||
|
||||
## 健康检查
|
||||
|
||||
可通过 `/api/health` 查看服务状态。
|
||||
52
docs/review-engines.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Review Engines
|
||||
|
||||
## Overview
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native Agent review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
|
||||
## Agent engine
|
||||
|
||||
The Agent engine runs code reviews using a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
|
||||
|
||||
### Review behavior
|
||||
|
||||
- **Main Agent**: The entrypoint agent that coordinates the review process. It uses the tools provided to analyze the code changes.
|
||||
- **Dynamic Subagents**: The main agent can dynamically spawn subagents to perform specific tasks, such as searching code or reading files, if needed.
|
||||
- **Deterministic Publishing**: Review findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting them back to Gitea.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: Low-risk changes may bypass the agent review entirely.
|
||||
- `light`: Minimal checks for low-risk code changes.
|
||||
- `full`: Full review for risky or larger changes.
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used to classify the change size, which determines the execution mode and token budgets.
|
||||
|
||||
## Codex engine
|
||||
|
||||
Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## Event support
|
||||
|
||||
Both engines process:
|
||||
|
||||
- Pull request webhook events
|
||||
- Commit status webhook events
|
||||
|
||||
## Output
|
||||
|
||||
- PR/commit summary comment
|
||||
- Line-level findings with confidence and severity
|
||||
52
docs/review-engines.zh-CN.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 审查引擎
|
||||
|
||||
## 概览
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:内置 Agent 审查流水线
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
|
||||
|
||||
### 审查行为
|
||||
|
||||
- **主 Agent**:协调审查流程的入口 Agent。它使用提供的工具来分析代码变更。
|
||||
- **动态子 Agent**:主 Agent 可以根据需要动态生成子 Agent,以执行特定任务(例如搜索代码或读取文件)。
|
||||
- **确定性发布**:审查发现的问题与评论会在 Agent 循环之外进行收集和处理。系统会在将结果发布回 Gitea 之前,对发现的问题进行确定性的规范化、去重和过滤。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可完全跳过 Agent 审查。
|
||||
- `light`:对低风险代码执行最小化检查。
|
||||
- `full`:对高风险或大规模改动执行完整审查。
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于对变更规模进行分类,从而决定执行模式与 Token 预算。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## 事件支持
|
||||
|
||||
两种引擎都支持:
|
||||
|
||||
- Pull Request webhook 事件
|
||||
- Commit Status webhook 事件
|
||||
|
||||
## 输出
|
||||
|
||||
- PR/提交总结评论
|
||||
- 行级问题(含置信度与严重性)
|
||||
23
docs/screenshots.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Screenshot Gallery
|
||||
|
||||
All screenshots are captured from local development service.
|
||||
|
||||
## Repository management (`/repos`)
|
||||
|
||||

|
||||
|
||||
## System configuration (`/config`)
|
||||
|
||||

|
||||
|
||||
## Notification management (`/notifications`)
|
||||
|
||||

|
||||
|
||||
## Review configuration (`/review-config`)
|
||||
|
||||

|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [screenshots.zh-CN.md](./screenshots.zh-CN.md)
|
||||
23
docs/screenshots.zh-CN.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 截图集
|
||||
|
||||
以下截图来自本地开发环境。
|
||||
|
||||
## 仓库管理(`/repos`)
|
||||
|
||||

|
||||
|
||||
## 系统配置(`/config`)
|
||||
|
||||

|
||||
|
||||
## 通知管理(`/notifications`)
|
||||
|
||||

|
||||
|
||||
## 审查配置(`/review-config`)
|
||||
|
||||

|
||||
|
||||
## 语言切换
|
||||
|
||||
- English: [screenshots.md](./screenshots.md)
|
||||
@@ -11,6 +11,6 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
258
e2e/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# E2E 真实 PR 审查测试指南
|
||||
|
||||
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- `openssl`(签名计算)
|
||||
- `python3`(JSON 解析)
|
||||
- 本项目源码
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
Gitea (port 3333) ←→ Assistant (port 3334)
|
||||
↑ ↑
|
||||
webhook clone + comment
|
||||
(PR 事件) (git + API)
|
||||
```
|
||||
|
||||
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`)
|
||||
- **Assistant**:AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`)
|
||||
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
|
||||
|
||||
## 一键启动(自动化方式)
|
||||
|
||||
```bash
|
||||
# 1. 启动容器
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# 2. 等待 Gitea healthy 后创建用户(Gitea 不允许 root 执行 admin 命令)
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
|
||||
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR)
|
||||
bash ./e2e/seed.sh
|
||||
|
||||
# 4. 用 seed 输出的 token 重启 assistant(使 GITEA_ACCESS_TOKEN 生效)
|
||||
# seed.sh 会在最后打印实际 token 值
|
||||
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
# 5. 通过 Admin API 更新运行时配置
|
||||
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}')
|
||||
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
|
||||
# 6. 运行 E2E 测试
|
||||
bash ./e2e/test.sh
|
||||
```
|
||||
|
||||
## 分步详解与踩坑点
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
```
|
||||
|
||||
**踩坑**:`ENCRYPTION_KEY` 和 `WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
|
||||
|
||||
```yaml
|
||||
assistant:
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...(64位hex)}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
```
|
||||
|
||||
### 2. 创建 Gitea 用户
|
||||
|
||||
```bash
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
```
|
||||
|
||||
**踩坑**:`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
|
||||
|
||||
### 3. Seed 初始化
|
||||
|
||||
```bash
|
||||
bash ./e2e/seed.sh
|
||||
```
|
||||
|
||||
Seed 脚本会执行:
|
||||
1. 等待 Gitea 就绪
|
||||
2. 创建管理员用户(如已存在则跳过)
|
||||
3. 生成 API Token
|
||||
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
|
||||
5. 配置 Assistant 设置(需要 assistant 已启动)
|
||||
6. 配置 Gitea Webhook(指向 `http://assistant:5174/webhook/gitea`)
|
||||
7. 创建 PR #1(`feature/add-user-handler` → `main`)
|
||||
|
||||
**踩坑**:seed.sh 第 5 步"配置 Assistant 设置"可能失败(assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
|
||||
|
||||
### 4. 更新 Assistant 运行时配置
|
||||
|
||||
```bash
|
||||
# 获取 JWT
|
||||
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}' | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# 更新三个关键配置
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`(Docker 内部地址),不是 `localhost` 或宿主机地址
|
||||
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 token,assistant 用它 clone 仓库和发布评论
|
||||
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
|
||||
|
||||
### 5. 触发 PR 审查
|
||||
|
||||
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发:
|
||||
|
||||
```bash
|
||||
# 获取 PR 信息
|
||||
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
|
||||
-H "Authorization: token <token>")
|
||||
|
||||
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
|
||||
|
||||
# 构造 webhook payload(需包含 head/base SHA)
|
||||
cat > /tmp/webhook_payload.json << EOF
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"title": "feat: add user handler",
|
||||
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
|
||||
"base": { "ref": "main", "sha": "$BASE_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "e2e-admin/e2e-test-repo",
|
||||
"name": "e2e-test-repo",
|
||||
"owner": { "login": "e2e-admin" },
|
||||
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
|
||||
},
|
||||
"sender": { "login": "e2e-admin" }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
|
||||
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
|
||||
|
||||
# 发送 webhook(⚠️ 必须用 --data-binary 而非 -d,否则换行符被剥离导致签名不匹配)
|
||||
curl -s -X POST "http://localhost:3334/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: pull_request" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
--data-binary @/tmp/webhook_payload.json
|
||||
```
|
||||
|
||||
### 6. 验证审查结果
|
||||
|
||||
```bash
|
||||
# 等待审查完成
|
||||
sleep 10
|
||||
|
||||
# 检查 assistant 日志
|
||||
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
|
||||
|
||||
# 通过 API 查看 run 详情
|
||||
curl -sf "http://localhost:3334/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
|
||||
|
||||
# 检查 Gitea PR 评论(summary)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
|
||||
-H "Authorization: token <token>" | python3 -c "
|
||||
import sys,json
|
||||
for c in json.load(sys.stdin):
|
||||
print(c['body'][:200])
|
||||
"
|
||||
|
||||
# 检查 Gitea PR Reviews(行级评论)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
|
||||
-H "Authorization: token <token>"
|
||||
```
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
| # | 检查项 | 验证方式 |
|
||||
|---|--------|----------|
|
||||
| 1 | Gitea 容器 healthy | `docker ps` 或 `curl localhost:3333/api/v1/version` |
|
||||
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
|
||||
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
|
||||
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
|
||||
| 5 | Agent 审查执行完成 | run status = `succeeded` |
|
||||
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
|
||||
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
|
||||
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
|
||||
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
|
||||
| 10 | Finding published=true | DB 中 finding.published = true |
|
||||
|
||||
## Mock LLM vs 真实 LLM
|
||||
|
||||
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|
||||
|------|----------------|----------|
|
||||
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
|
||||
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
|
||||
| Findings | 固定 1 条(eval 安全问题) | 根据实际代码动态发现 |
|
||||
| 速度 | <1s | 10-60s |
|
||||
| 用途 | 集成链路验证 | 审查质量验证 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ENCRYPTION_KEY is required`
|
||||
**原因**:`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
|
||||
**修复**:已在 compose 文件中添加默认值。
|
||||
|
||||
### `Webhook签名验证失败`
|
||||
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
|
||||
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
|
||||
|
||||
### `could not read Username for 'http://gitea:3000'`
|
||||
**原因**:`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
|
||||
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
|
||||
|
||||
### `Gitea is not supposed to be run as root`
|
||||
**原因**:Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
|
||||
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
|
||||
|
||||
### Gitea API URL 指向 localhost
|
||||
**原因**:assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
|
||||
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`(Docker 内部地址)。
|
||||
|
||||
### 评论未发布到 Gitea
|
||||
**原因**:Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
|
||||
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。
|
||||
55
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
docker exec e2e-gitea su git -c "gitea admin user create \
|
||||
--username '${ADMIN_USER}' \
|
||||
--password '${ADMIN_PASS}' \
|
||||
--email '${ADMIN_EMAIL}' \
|
||||
--admin \
|
||||
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
@@ -115,7 +115,43 @@ git commit -m "feat: add user handler"
|
||||
git push origin feature/add-user-handler 2>/dev/null
|
||||
popd > /dev/null
|
||||
|
||||
echo "=== [5/6] 配置 Webhook ==="
|
||||
echo "=== [5/7] 配置 Assistant 设置 ==="
|
||||
ADMIN_DEFAULT_PASS="password"
|
||||
|
||||
# Wait for assistant to be healthy
|
||||
for i in $(seq 1 20); do
|
||||
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
break
|
||||
fi
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
echo "=== [6/7] 配置 Webhook ==="
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -124,13 +160,12 @@ curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
|
||||
\"active\": true,
|
||||
\"events\": [\"pull_request\"],
|
||||
\"config\": {
|
||||
\"url\": \"http://assistant:3000/webhook/gitea\",
|
||||
\"url\": \"http://assistant:5174/webhook/gitea\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${WEBHOOK_SECRET}\"
|
||||
}
|
||||
}" > /dev/null 2>&1 || echo " Webhook 配置失败(可能已存在)"
|
||||
|
||||
echo "=== [6/6] 创建 Pull Request ==="
|
||||
echo "=== [7/7] 创建 Pull Request ==="
|
||||
PR_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
179
e2e/test.sh
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
@@ -17,10 +16,12 @@ fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
MAX_WAIT=240
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RUN_ID=""
|
||||
LATEST_DETAIL='{}'
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
@@ -38,6 +39,12 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
|
||||
echo " E2E_MOCK_LLM=1 (shell env)"
|
||||
else
|
||||
echo " E2E_MOCK_LLM 由 assistant 容器环境决定(docker-compose.e2e.yml 已配置)"
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
@@ -63,69 +70,121 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
echo "[TEST 4] Admin 登录"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "${ADMIN_JWT}" ]; then
|
||||
echo " ✅ PASS: Admin JWT 获取成功"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Admin JWT 获取失败"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s)"
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
WAITED=0
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
|
||||
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
|
||||
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
|
||||
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
|
||||
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
if [ -z "${RUN_ID}" ]; then
|
||||
echo " ❌ FAIL: 未发现 review run"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ -n "${RUN_ID}" ]; then
|
||||
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
|
||||
fi
|
||||
|
||||
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
|
||||
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
t=d.get("sessionTree") or {}
|
||||
main_type=t.get("agentType")
|
||||
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
|
||||
inv=t.get("invocations",[])
|
||||
has_spawn="spawn_subagent" in main_tools
|
||||
child_ok=False
|
||||
if inv:
|
||||
child=inv[0].get("childSession") or {}
|
||||
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
|
||||
child_ok=("search_code" in child_tools and "read_file" in child_tools)
|
||||
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${TREE_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
|
||||
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 7] run details 包含 findings 与评论记录"
|
||||
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
findings=d.get("findings",[])
|
||||
comments=d.get("comments",[])
|
||||
ok=(len(findings) > 0 and len(comments) > 0)
|
||||
print("ok" if ok else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${DETAIL_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: run details 存在 findings/comments"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: run details 缺少 findings 或 comments"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 8] Gitea 评论产物(summary + line comments)"
|
||||
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
|
||||
import json,sys
|
||||
arr=json.load(sys.stdin)
|
||||
cnt=0
|
||||
for c in arr:
|
||||
body=c.get("body") or ""
|
||||
if "审查" in body or "review" in body.lower() or "AI" in body:
|
||||
cnt += 1
|
||||
print(cnt)
|
||||
' 2>/dev/null || echo "0")
|
||||
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea 评论产物不足(summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT})"
|
||||
echo " --- issue comments ---"
|
||||
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
echo " --- line comments ---"
|
||||
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
echo "⚠️ 部分测试失败。请检查:"
|
||||
echo " 1. docker compose e2e 容器均 healthy"
|
||||
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
|
||||
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
|
||||
4
frontend/.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Gitea AI Assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"ui:regression": "bun run scripts/ui-regression.ts && vitest run src/components/llm/__tests__/ModelCombobox.test.tsx src/components/llm/__tests__/ProviderList.test.tsx src/components/llm/__tests__/RoleAssignment.test.tsx src/components/llm/__tests__/TestResultDialog.test.tsx",
|
||||
"ui:visual": "playwright test -c playwright.config.ts",
|
||||
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -30,7 +36,11 @@
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -40,12 +50,14 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^7.1.7"
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.PW_PORT ?? 4173);
|
||||
const baseURL = process.env.PW_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/visual',
|
||||
timeout: 30_000,
|
||||
forbidOnly: !!process.env.CI,
|
||||
expect: {
|
||||
timeout: 8_000,
|
||||
toHaveScreenshot: {
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
scale: 'css',
|
||||
maxDiffPixelRatio: 0.012,
|
||||
stylePath: './tests/visual/fixtures/screenshot.css',
|
||||
},
|
||||
},
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
],
|
||||
fullyParallel: false,
|
||||
use: {
|
||||
baseURL,
|
||||
deviceScaleFactor: 1,
|
||||
hasTouch: false,
|
||||
isMobile: false,
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-lcd-text',
|
||||
'--disable-font-subpixel-positioning',
|
||||
'--font-render-hinting=none',
|
||||
'--force-color-profile=srgb',
|
||||
'--hide-scrollbars',
|
||||
],
|
||||
},
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
webServer: process.env.PW_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: `bun run dev --host 127.0.0.1 --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
1
frontend/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="hsl(175, 90%, 45%)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
|
||||
|
After Width: | Height: | Size: 342 B |
151
frontend/scripts/ui-regression.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Glob } from 'bun';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
type Violation = {
|
||||
file: string;
|
||||
line: number;
|
||||
reason: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
reason: string;
|
||||
regex: RegExp;
|
||||
};
|
||||
|
||||
type Requirement = {
|
||||
file: string;
|
||||
reason: string;
|
||||
needles: string[];
|
||||
};
|
||||
|
||||
const rules: Rule[] = [
|
||||
{ reason: 'hardcoded zinc utility', regex: /\b(?:bg|text|border)-zinc-\d{2,3}\b/ },
|
||||
{ reason: 'hardcoded white-alpha border', regex: /\bborder-white\/\d+\b/ },
|
||||
{ reason: 'hardcoded black overlay', regex: /\bbg-black\/\d+\b/ },
|
||||
{ reason: 'inline rgba color literal', regex: /rgba\(/ },
|
||||
{ reason: 'inline rgb color literal', regex: /rgb\(/ },
|
||||
{ reason: 'hex color literal', regex: /#[0-9a-fA-F]{3,8}\b/ },
|
||||
{ reason: 'legacy red semantic marker', regex: /text-red-500/ },
|
||||
{ reason: 'primary-tinted hover on non-primary surfaces', regex: /\b(?:hover:bg-primary\/(?:[1-8]0)|group-hover:bg-primary\/(?:[1-8]0))\b/ },
|
||||
];
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
{
|
||||
file: 'src/index.css',
|
||||
reason: 'theme token and utility baseline',
|
||||
needles: [
|
||||
'--success:',
|
||||
'--warning:',
|
||||
'--danger:',
|
||||
'--info:',
|
||||
'--surface-muted:',
|
||||
'--surface-elevated:',
|
||||
'--surface-overlay:',
|
||||
'.theme-glow-primary',
|
||||
'.theme-surface-overlay',
|
||||
'.theme-sticky-bar',
|
||||
'.theme-page-frame',
|
||||
'.theme-page-actions',
|
||||
'.theme-page-content',
|
||||
'.theme-card-shell',
|
||||
'.theme-card-header',
|
||||
'.theme-card-content',
|
||||
'.theme-error-panel',
|
||||
'.theme-dialog-panel',
|
||||
'.theme-dialog-header',
|
||||
'.theme-dialog-body',
|
||||
'.theme-dialog-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'tailwind.config.js',
|
||||
reason: 'semantic color mapping in Tailwind',
|
||||
needles: ['danger:', 'success:', 'warning:', 'info:'],
|
||||
},
|
||||
{
|
||||
file: 'src/components/llm/ProviderDialog.tsx',
|
||||
reason: 'modal overlay must use semantic utility',
|
||||
needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'],
|
||||
},
|
||||
{
|
||||
file: 'src/components/llm/TestResultDialog.tsx',
|
||||
reason: 'modal overlay must use semantic utility',
|
||||
needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'],
|
||||
},
|
||||
{
|
||||
file: '../docs/design/ui-theme-language.md',
|
||||
reason: 'design language documentation baseline',
|
||||
needles: ['强制规则', '页面级统一约束', 'destructive 与 danger 的约定', 'theme-surface-overlay'],
|
||||
},
|
||||
{
|
||||
file: 'src/components/ConfigManager.tsx',
|
||||
reason: 'system config page should use unified page shell utilities',
|
||||
needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-error-panel'],
|
||||
},
|
||||
{
|
||||
file: 'src/components/ReviewConfigPage.tsx',
|
||||
reason: 'review config page should use unified page shell utilities',
|
||||
needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-card-shell', 'theme-error-panel'],
|
||||
},
|
||||
];
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
const addViolation = (file: string, line: number, reason: string, snippet: string) => {
|
||||
violations.push({ file: relative(process.cwd(), file), line, reason, snippet });
|
||||
};
|
||||
|
||||
const scanSourceFiles = async () => {
|
||||
const glob = new Glob('src/**/*.tsx');
|
||||
|
||||
for await (const file of glob.scan('.')) {
|
||||
const absoluteFile = resolve(process.cwd(), file);
|
||||
const content = await Bun.file(absoluteFile).text();
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach((lineContent, index) => {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.regex.test(lineContent)) {
|
||||
addViolation(absoluteFile, index + 1, rule.reason, lineContent.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRequirements = async () => {
|
||||
for (const requirement of requirements) {
|
||||
const absoluteFile = resolve(process.cwd(), requirement.file);
|
||||
|
||||
if (!existsSync(absoluteFile)) {
|
||||
addViolation(absoluteFile, 1, requirement.reason, 'required file missing');
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await Bun.file(absoluteFile).text();
|
||||
requirement.needles.forEach((needle) => {
|
||||
if (!content.includes(needle)) {
|
||||
addViolation(absoluteFile, 1, requirement.reason, `missing token/pattern: ${needle}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const printResult = () => {
|
||||
if (violations.length === 0) {
|
||||
console.log('✅ UI regression guard passed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`❌ UI regression guard failed with ${violations.length} issue(s):`);
|
||||
violations.forEach((violation) => {
|
||||
console.error(`- ${violation.file}:${violation.line} [${violation.reason}] ${violation.snippet}`);
|
||||
});
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
await scanSourceFiles();
|
||||
await verifyRequirements();
|
||||
printResult();
|
||||
@@ -4,7 +4,12 @@ import { LoginPage } from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { ColorPaletteProvider } from './hooks/useColorPalette';
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -15,7 +20,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative flex h-12 w-12 items-center justify-center">
|
||||
<div className="absolute h-full w-full rounded-full border-b-2 border-primary animate-spin"></div>
|
||||
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||||
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 opacity-50 theme-spin-reverse-slow"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-sm font-mono tracking-widest text-primary/80 animate-pulse">INITIALIZING...</div>
|
||||
@@ -31,7 +36,9 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
function AppContent() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
@@ -46,12 +53,23 @@ function App() {
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<Route path="repos" element={<RepositoryManager />} />
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="review-runs" element={<ReviewSessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster theme="dark" />
|
||||
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ColorPaletteProvider>
|
||||
<AppContent />
|
||||
</ColorPaletteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
interface ConfigFieldInputProps {
|
||||
field: ConfigFieldDto;
|
||||
@@ -15,18 +14,15 @@ interface ConfigFieldInputProps {
|
||||
}
|
||||
|
||||
export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
||||
const isReadonly = !!field.readonly;
|
||||
|
||||
const renderInput = () => {
|
||||
const baseInputClasses = "bg-zinc-900/50 border-white/10 focus-visible:ring-primary focus-visible:border-primary transition-all duration-200" + (isReadonly ? " opacity-50 cursor-not-allowed" : "");
|
||||
const baseInputClasses = "bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200";
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={isReadonly}
|
||||
className={`data-[state=checked]:bg-primary ${isReadonly ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
);
|
||||
case 'enum':
|
||||
@@ -34,14 +30,13 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
<Select
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<SelectTrigger className={`w-full ${baseInputClasses}`}>
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-950 border-white/10">
|
||||
<SelectContent className="bg-popover border-border">
|
||||
{field.enumValues?.map((val) => (
|
||||
<SelectItem key={val} value={val} className="focus:bg-zinc-900 focus:text-primary">
|
||||
<SelectItem key={val} value={val} className="focus:bg-accent focus:text-primary">
|
||||
{val}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -55,7 +50,6 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
className={`min-h-[100px] ${baseInputClasses}`}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
@@ -67,7 +61,6 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={isReadonly}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
);
|
||||
@@ -80,7 +73,6 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
disabled={isReadonly}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
);
|
||||
@@ -89,50 +81,34 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
|
||||
const getSourceBadge = () => {
|
||||
switch (field.source) {
|
||||
case 'override':
|
||||
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-primary/30 transition-colors">覆盖值</Badge>;
|
||||
case 'env':
|
||||
return <Badge variant="secondary" className="ml-2 bg-amber-500/20 text-amber-500 border-amber-500/30 hover:bg-amber-500/30">环境变量</Badge>;
|
||||
case 'db':
|
||||
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-accent hover:text-foreground hover:border-border/70 transition-colors">已配置</Badge>;
|
||||
case 'default':
|
||||
default:
|
||||
return <Badge variant="outline" className="ml-2 border-zinc-600 text-zinc-400">默认值</Badge>;
|
||||
return <Badge variant="outline" className="ml-2 border-border text-muted-foreground">默认值</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-zinc-900/30 transition-colors rounded-lg">
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1.5 flex-1">
|
||||
<div className="flex items-center">
|
||||
<Label className="text-base font-semibold text-zinc-100">{field.label || field.envKey}</Label>
|
||||
<Label className="text-base font-semibold text-foreground">{field.label || field.envKey}</Label>
|
||||
{getSourceBadge()}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 leading-relaxed">
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">
|
||||
{field.description}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
$ {field.envKey}
|
||||
{field.envKey}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
{renderInput()}
|
||||
|
||||
{isReadonly && (
|
||||
<div className="text-xs text-zinc-500 flex items-center gap-1.5 pt-1">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
只读配置,请通过环境变量文件修改
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReadonly && field.readonlyWarning && (
|
||||
<div className="text-xs text-amber-500 flex items-center gap-1.5 pt-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
{field.readonlyWarning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import type { ConfigGroupDto } from '@/services/configService';
|
||||
import React from 'react';
|
||||
import type { ConfigGroupDto, ConfigFieldDto } from '@/services/configService';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfigFieldInput } from './ConfigFieldInput';
|
||||
@@ -24,6 +25,9 @@ interface ConfigGroupCardProps {
|
||||
onFieldChange: (envKey: string, value: any) => void;
|
||||
onReset: (keys: string[]) => void;
|
||||
isResetting: boolean;
|
||||
headerActions?: React.ReactNode;
|
||||
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
||||
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
||||
}
|
||||
|
||||
export function ConfigGroupCard({
|
||||
@@ -32,13 +36,15 @@ export function ConfigGroupCard({
|
||||
onFieldChange,
|
||||
onReset,
|
||||
isResetting,
|
||||
headerActions,
|
||||
renderField,
|
||||
}: ConfigGroupCardProps) {
|
||||
const hasOverride = group.fields.some((f) => f.source === 'override');
|
||||
const hasOverride = group.fields.some((f) => f.source === 'db');
|
||||
|
||||
const handleReset = () => {
|
||||
// Only reset fields that actually have overrides
|
||||
// Only reset fields that have been stored in DB
|
||||
const keysToReset = group.fields
|
||||
.filter((f) => f.source === 'override')
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (keysToReset.length > 0) {
|
||||
@@ -47,46 +53,55 @@ export function ConfigGroupCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-8 glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
|
||||
<Card className="mb-8 gap-0 py-0 theme-card-shell theme-interactive-elevate group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-start justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
{(() => {
|
||||
const Icon = ICON_MAP[group.icon];
|
||||
return Icon ? <Icon className="h-5 w-5 text-primary" /> : <span className="text-primary">{group.icon}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{group.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
{(headerActions || hasOverride) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-white/5 p-6 bg-zinc-950/20">
|
||||
{group.fields.map((field) => (
|
||||
<ConfigFieldInput
|
||||
key={field.envKey}
|
||||
field={field}
|
||||
value={localConfig[field.envKey]}
|
||||
onChange={(val) => onFieldChange(field.envKey, val)}
|
||||
/>
|
||||
))}
|
||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||
{group.fields.map((field) => {
|
||||
const custom = renderField?.(field, localConfig[field.envKey], (val) => onFieldChange(field.envKey, val));
|
||||
if (custom !== undefined) return <React.Fragment key={field.envKey}>{custom}</React.Fragment>;
|
||||
return (
|
||||
<ConfigFieldInput
|
||||
key={field.envKey}
|
||||
field={field}
|
||||
value={localConfig[field.envKey]}
|
||||
onChange={(val) => onFieldChange(field.envKey, val)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'security']);
|
||||
|
||||
export function ConfigManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
@@ -22,7 +25,7 @@ export function ConfigManager() {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups.forEach((group) => {
|
||||
data.groups.filter((g) => SYSTEM_GROUPS.has(g.key)).forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
@@ -96,8 +99,9 @@ export function ConfigManager() {
|
||||
const handleResetAll = () => {
|
||||
if (!data) return;
|
||||
const allOverrideKeys = data.groups
|
||||
.filter((g) => SYSTEM_GROUPS.has(g.key))
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'override')
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有配置到默认值吗?这将立即生效。')) {
|
||||
@@ -105,42 +109,44 @@ export function ConfigManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const hasOverrides = data?.groups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'override')
|
||||
const visibleGroups = data?.groups.filter((g) => SYSTEM_GROUPS.has(g.key));
|
||||
|
||||
const hasOverrides = visibleGroups?.some((g) =>
|
||||
g.fields.some((f) => f.source === 'db')
|
||||
) ?? false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-24 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 rounded-lg flex items-center gap-3 glass-panel">
|
||||
<AlertCircle className="w-5 h-5 text-rose-500" />
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<div className="theme-page-frame">
|
||||
{/* 固定在顶部的操作栏 */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950/80 backdrop-blur-xl border-b border-white/10 py-3 px-4 md:px-6 lg:px-8 shadow-2xl">
|
||||
<div className="flex items-center justify-end gap-3 max-w-5xl mx-auto">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="border-white/10 text-zinc-400 hover:text-zinc-100 hover:bg-white/5 transition-colors"
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
@@ -148,11 +154,11 @@ export function ConfigManager() {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="min-w-[130px] bg-primary text-zinc-950 font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-zinc-950 border-t-transparent" /> 保存中...
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -164,8 +170,8 @@ export function ConfigManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
|
||||
{data?.groups.map((group) => (
|
||||
<div className="theme-page-content">
|
||||
{visibleGroups?.map((group) => (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
|
||||
@@ -32,14 +32,14 @@ export function DataTable<TData, TValue>({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<div className="theme-card-shell overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-900 text-zinc-400 uppercase tracking-wider font-mono text-xs">
|
||||
<TableHeader className="bg-muted/45 text-muted-foreground uppercase tracking-wider font-mono text-xs border-b border-border/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="font-mono text-zinc-400">
|
||||
<TableHead key={header.id} className="font-mono text-muted-foreground">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -58,7 +58,7 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-zinc-900/50 transition-colors border-border/50"
|
||||
className="hover:bg-accent/35 transition-colors border-border/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -69,9 +69,9 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center text-zinc-500 font-mono">
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center text-muted-foreground font-mono">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="p-3 rounded-full bg-zinc-900 border border-white/5 text-zinc-600">
|
||||
<div className="p-3 rounded-full bg-muted border border-border text-muted-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
</div>
|
||||
<p>未找到匹配的仓库</p>
|
||||
|
||||
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchConfig,
|
||||
fetchNotificationTestHistory,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
testNotification,
|
||||
type NotificationTestProvider,
|
||||
} from '@/services/configService';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigGroupDto,
|
||||
ConfigFieldDto,
|
||||
NotificationTestRecordDto,
|
||||
} from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const NOTIFICATION_GROUPS = new Set(['notification']);
|
||||
|
||||
type ProviderCardMeta = {
|
||||
key: NotificationTestProvider;
|
||||
fieldPrefix: 'FEISHU_' | 'WECOM_';
|
||||
label: string;
|
||||
description: string;
|
||||
enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED';
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL';
|
||||
};
|
||||
|
||||
const PROVIDER_CARDS: ProviderCardMeta[] = [
|
||||
{
|
||||
key: 'feishu',
|
||||
fieldPrefix: 'FEISHU_',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书机器人 Webhook 与签名密钥。',
|
||||
enableKey: 'FEISHU_ENABLED',
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL',
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
fieldPrefix: 'WECOM_',
|
||||
label: '企业微信通知',
|
||||
description: '配置企业微信群机器人 Webhook。',
|
||||
enableKey: 'WECOM_ENABLED',
|
||||
webhookKey: 'WECOM_WEBHOOK_URL',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
data: testHistory,
|
||||
isLoading: isHistoryLoading,
|
||||
} = useQuery<NotificationTestRecordDto[], Error>({
|
||||
queryKey: ['notification-test-history'],
|
||||
queryFn: fetchNotificationTestHistory,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => NOTIFICATION_GROUPS.has(g.key))
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else if (field.type === 'boolean') {
|
||||
initialState[field.envKey] = field.value === 'true' || field.value === true;
|
||||
} else {
|
||||
initialState[field.envKey] = field.value ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
setLocalConfig(initialState);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已成功保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const feishuTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('feishu'),
|
||||
onSuccess: () => {
|
||||
toast.success('飞书测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`飞书测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const wecomTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('wecom'),
|
||||
onSuccess: () => {
|
||||
toast.success('企业微信测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`企业微信测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (keys: string[]) => resetConfig(keys),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已重置');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`重置失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (envKey: string, value: any) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
[envKey]: value,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
payload[key] = val === undefined || val === null ? '' : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleResetGroup = (keys: string[]) => {
|
||||
if (confirm('确定要重置这些通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const notificationGroup = useMemo(
|
||||
() => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)),
|
||||
[data]
|
||||
);
|
||||
|
||||
const providerGroups = useMemo<ConfigGroupDto[]>(() => {
|
||||
if (!notificationGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PROVIDER_CARDS.map((provider) => {
|
||||
const fields = notificationGroup.fields.filter((field: ConfigFieldDto) =>
|
||||
field.envKey.startsWith(provider.fieldPrefix)
|
||||
);
|
||||
|
||||
return {
|
||||
...notificationGroup,
|
||||
key: `notification-${provider.key}`,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
fields,
|
||||
};
|
||||
}).filter((group) => group.fields.length > 0);
|
||||
}, [notificationGroup]);
|
||||
|
||||
const hasOverrides = useMemo(
|
||||
() =>
|
||||
providerGroups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'db')
|
||||
),
|
||||
[providerGroups]
|
||||
);
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (providerGroups.length === 0) return;
|
||||
const allOverrideKeys = providerGroups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const canSendProviderTest = (provider: ProviderCardMeta): boolean => {
|
||||
const enabled = localConfig[provider.enableKey] === true;
|
||||
const webhook = localConfig[provider.webhookKey];
|
||||
return enabled && typeof webhook === 'string' && webhook.trim().length > 0;
|
||||
};
|
||||
|
||||
const getProviderMutation = (providerKey: NotificationTestProvider) => {
|
||||
return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation;
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string): string => {
|
||||
if (provider === 'feishu') return '飞书';
|
||||
if (provider === 'wecom') return '企业微信';
|
||||
return provider;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载通知配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page-content">
|
||||
{providerGroups.map((group) => {
|
||||
const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutation = getProviderMutation(provider.key);
|
||||
const canTest = canSendProviderTest(provider);
|
||||
const canTestNow = canTest && !hasChanges && !saveMutation.isPending;
|
||||
const testTitle = hasChanges
|
||||
? '请先保存配置后再测试'
|
||||
: canTest
|
||||
? '发送测试通知'
|
||||
: '请先启用并配置Webhook地址';
|
||||
|
||||
return (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
headerActions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !canTestNow}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
title={testTitle}
|
||||
>
|
||||
{mutation.isPending ? '测试中...' : '测试发送'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold tracking-wide text-foreground">最近测试记录</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isHistoryLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
</div>
|
||||
) : (testHistory?.length ?? 0) === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
暂无测试记录,点击上方“测试发送”按钮可生成记录。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{testHistory?.slice(0, 10).map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-border text-foreground">
|
||||
{getProviderLabel(record.provider)}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
record.status === 'success'
|
||||
? 'bg-success/15 text-success border-success/30'
|
||||
: 'bg-danger/15 text-danger border-danger/30'
|
||||
}
|
||||
>
|
||||
{record.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{record.message}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(record.timestamp).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Settings, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface RepositoryConfigCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
|
||||
const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
|
||||
|
||||
const promptMutation = useMutation({
|
||||
mutationFn: async (prompt: string) => {
|
||||
const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
|
||||
project_review_prompt: prompt,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
setIsPromptDialogOpen(false);
|
||||
toast.success(`已更新 ${repo.name} 的项目级提示词`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`更新失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSavePrompt = () => {
|
||||
promptMutation.mutate(draftPrompt.trim());
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setDraftPrompt(repo.project_review_prompt ?? '');
|
||||
setIsPromptDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasPrompt = !!repo.project_review_prompt?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={hasPrompt ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={`h-8 gap-1.5 text-xs ${
|
||||
hasPrompt
|
||||
? "border-primary/50 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={handleOpenDialog}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{hasPrompt ? '已配置' : '配置'}</span>
|
||||
{hasPrompt && <span className="ml-1 h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isPromptDialogOpen} onOpenChange={setIsPromptDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>配置项目级提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
为仓库 <code className="rounded bg-muted px-1 py-0.5 text-xs">{repo.name}</code> 设置审查提示词
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{hasPrompt && (
|
||||
<div className="rounded-lg bg-muted/50 border border-border/50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">当前配置</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap break-all max-h-[80px] overflow-y-auto">
|
||||
{repo.project_review_prompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">提示词内容</label>
|
||||
<Textarea
|
||||
value={draftPrompt}
|
||||
onChange={(e) => setDraftPrompt(e.target.value)}
|
||||
placeholder="输入项目级审查提示词,例如:重点关注 API 安全性、空值处理和错误边界..."
|
||||
className="min-h-[120px] resize-none text-sm leading-relaxed focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
disabled={promptMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
此提示词将在代码审查时与全局提示词合并,传递给 AI 模型。
|
||||
{hasPrompt && ' 留空保存将清除当前配置。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPromptDialogOpen(false)}
|
||||
disabled={promptMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePrompt}
|
||||
disabled={
|
||||
promptMutation.isPending ||
|
||||
draftPrompt.trim() === (repo.project_review_prompt ?? '').trim()
|
||||
}
|
||||
>
|
||||
{promptMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
保存中
|
||||
</>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,19 +15,19 @@ function DataTableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-900 border-b border-border/50">
|
||||
<TableHeader className="bg-muted/60 border-b border-border/50">
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[50%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%]"><Skeleton className="h-5 w-16 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-zinc-800/80" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-zinc-800/80 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-zinc-800/80" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-20 bg-muted/70" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-24 ml-auto bg-muted/70" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -60,31 +60,33 @@ export function RepositoryManager() {
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
|
||||
<div className="space-y-6">
|
||||
<div className="theme-card-shell p-4 md:p-5">
|
||||
<div className="flex justify-end">
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索仓库..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] bg-zinc-900/50 border-border/50 text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary transition-all font-mono text-sm"
|
||||
className="theme-input-surface pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] focus-visible:ring-1 transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton />
|
||||
) : isError ? (
|
||||
<div className="p-6 rounded-xl border border-rose-500/20 bg-rose-500/5 glass-panel">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
|
||||
<div className="theme-error-panel p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-danger/10 rounded-lg text-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-mono text-sm font-medium text-rose-500">System Error_</h3>
|
||||
<p className="font-mono text-xs text-rose-400/80">加载仓库列表失败: {error.message}</p>
|
||||
<h3 className="font-mono text-sm font-medium text-danger">System Error_</h3>
|
||||
<p className="font-mono text-xs text-danger/80">加载仓库列表失败: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,8 +95,8 @@ export function RepositoryManager() {
|
||||
<DataTable columns={columns} data={repos} />
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between w-full mt-6 gap-4">
|
||||
<div className="font-mono text-xs text-zinc-400 flex-shrink-0 bg-zinc-900/50 px-3 py-1.5 rounded-md border border-white/5">
|
||||
第 <span className="text-zinc-200">{page}</span> 页 / 共 <span className="text-zinc-200">{totalPages}</span> 页 <span className="text-zinc-600 mx-1">|</span> 共 <span className="text-zinc-200">{totalCount}</span> 个仓库
|
||||
<div className="theme-control-pill font-mono flex-shrink-0 rounded-md">
|
||||
第 <span className="text-foreground">{page}</span> 页 / 共 <span className="text-foreground">{totalPages}</span> 页 <span className="text-muted-foreground/70 mx-1">|</span> 共 <span className="text-foreground">{totalCount}</span> 个仓库
|
||||
</div>
|
||||
<Pagination className="flex-shrink-0 w-auto mx-0">
|
||||
<PaginationContent className="gap-2">
|
||||
@@ -105,7 +107,7 @@ export function RepositoryManager() {
|
||||
e.preventDefault();
|
||||
setPage(p => Math.max(1, p - 1));
|
||||
}}
|
||||
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
|
||||
className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
@@ -115,7 +117,7 @@ export function RepositoryManager() {
|
||||
e.preventDefault();
|
||||
setPage(p => Math.min(totalPages, p + 1));
|
||||
}}
|
||||
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
|
||||
className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
|
||||
@@ -2,42 +2,34 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import type { Repository } from "@/services/repositoryService"
|
||||
import { WebhookToggleButton } from "@/components/WebhookToggleButton"
|
||||
import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
|
||||
import { WebhookToggleCell } from "@/components/WebhookToggleCell"
|
||||
|
||||
export const columns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "仓库名称",
|
||||
cell: ({ row }) => <div className="font-medium text-zinc-100 text-sm">{row.getValue("name")}</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{row.getValue("name")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "webhook_status",
|
||||
header: "Webhook 状态",
|
||||
header: "Webhook",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("webhook_status") as Repository["webhook_status"]
|
||||
const isActive = status === 'active'
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-transparent text-zinc-500 border-zinc-700'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" style={{ boxShadow: '0 0 8px 1px rgba(52, 211, 153, 0.6)' }}></span>}
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</div>
|
||||
)
|
||||
const repo = row.original
|
||||
return <WebhookToggleCell repo={repo} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right text-zinc-400">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const repo = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<WebhookToggleButton
|
||||
repoName={repo.name}
|
||||
status={repo.webhook_status}
|
||||
hookId={repo.hook_id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
header: () => <div className="text-right text-muted-foreground text-xs">提示词</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<RepositoryConfigCell repo={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
367
frontend/src/components/ReviewConfigPage.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchConfig, updateConfig, resetConfig } from '@/services/configService';
|
||||
import type { ConfigResponse, ConfigGroupDto, ConfigFieldDto } from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { ModelCombobox } from './llm/ModelCombobox';
|
||||
import { ProviderList } from './llm/ProviderList';
|
||||
import { RoleAssignment } from './llm/RoleAssignment';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Save, AlertCircle, RotateCcw, Layers } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine-specific field visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EngineMode = 'agent' | 'codex';
|
||||
|
||||
/** The engine selector field — always visible at the top. */
|
||||
const ENGINE_FIELD = 'REVIEW_ENGINE';
|
||||
|
||||
const AGENT_SHARED_FIELDS = new Set([
|
||||
'GLOBAL_PROMPT',
|
||||
'REVIEW_WORKDIR',
|
||||
'REVIEW_MAX_PARALLEL_RUNS',
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
]);
|
||||
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
]);
|
||||
|
||||
/** Fields specific to codex mode only. */
|
||||
const CODEX_FIELDS = new Set([
|
||||
'CODEX_API_URL',
|
||||
'CODEX_API_KEY',
|
||||
'CODEX_MODEL',
|
||||
'CODEX_TIMEOUT_MS',
|
||||
'CODEX_REVIEW_PROMPT',
|
||||
'REVIEW_WORKDIR',
|
||||
'REVIEW_MAX_PARALLEL_RUNS',
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
]);
|
||||
|
||||
/** Field rendered with ModelCombobox instead of plain input. */
|
||||
const CODEX_MODEL_FIELD = 'CODEX_MODEL';
|
||||
|
||||
function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigFieldDto[] {
|
||||
return fields.filter((f) => {
|
||||
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
|
||||
switch (engine) {
|
||||
case 'agent':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
|
||||
case 'codex':
|
||||
return CODEX_FIELDS.has(f.envKey);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine selector badges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
|
||||
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
|
||||
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReviewConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
// Derived: current engine mode
|
||||
const engine: EngineMode = useMemo(() => {
|
||||
const val = localConfig[ENGINE_FIELD];
|
||||
if (val === 'agent' || val === 'codex') return val;
|
||||
return 'agent';
|
||||
}, [localConfig]);
|
||||
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => g.key === 'review')
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else if (field.type === 'boolean') {
|
||||
initialState[field.envKey] = field.value === 'true' || field.value === true;
|
||||
} else {
|
||||
initialState[field.envKey] = field.value ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
setLocalConfig(initialState);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('审查配置已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (keys: string[]) => resetConfig(keys),
|
||||
onSuccess: () => {
|
||||
toast.success('配置已重置');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`重置失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (envKey: string, value: any) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [envKey]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
|
||||
|
||||
for (const key of fieldsToSave) {
|
||||
const val = localConfig[key];
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
payload[key] = val === undefined || val === null ? '' : String(val);
|
||||
}
|
||||
}
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleResetGroup = (keys: string[]) => {
|
||||
if (confirm('确定要重置这些配置到默认值吗?这将立即生效并重载关联设置。')) {
|
||||
resetMutation.mutate(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
// Derived: visible fields for the current engine
|
||||
const visibleReviewFields = useMemo(
|
||||
() => (reviewGroup ? getVisibleFields(engine, reviewGroup.fields) : []),
|
||||
[engine, reviewGroup]
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[200px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build a synthetic group for the visible review fields
|
||||
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
|
||||
? {
|
||||
...reviewGroup,
|
||||
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: 'Agent 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
|
||||
/** Custom field renderer: CODEX_MODEL uses ModelCombobox for tokenlens suggestions. */
|
||||
const renderReviewField = engine === 'codex'
|
||||
? (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => {
|
||||
if (field.envKey !== CODEX_MODEL_FIELD) return undefined;
|
||||
// Replicate ConfigFieldInput layout with ModelCombobox as the input control
|
||||
const sourceBadge = field.source === 'db'
|
||||
? <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-accent hover:text-foreground hover:border-border/70 transition-colors">已配置</Badge>
|
||||
: <Badge variant="outline" className="ml-2 border-border text-muted-foreground">默认值</Badge>;
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1.5 flex-1">
|
||||
<div className="flex items-center">
|
||||
<label className="text-base font-semibold text-foreground">{field.label || field.envKey}</label>
|
||||
{sourceBadge}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">{field.description}</div>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{field.envKey}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
<ModelCombobox
|
||||
providerType="openai_compatible"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder="选择或输入模型..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
{/* Sticky action bar */}
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page-content">
|
||||
{/* Engine Selector Card */}
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
<Layers className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">审查引擎</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">选择代码审查引擎模式</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{ENGINE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleFieldChange(ENGINE_FIELD, opt.value)}
|
||||
className={`relative flex flex-col items-start gap-2 rounded-xl border p-4 text-left transition-all duration-200 ${
|
||||
engine === opt.value
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border bg-muted/30 hover:bg-muted/50 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-foreground">{opt.label}</span>
|
||||
{engine === opt.value && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 text-xs">当前</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{opt.description}</span>
|
||||
{engine === opt.value && (
|
||||
<div className="absolute top-0 right-0 w-3 h-3 m-2 rounded-full bg-primary theme-glow-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Engine-specific review config fields */}
|
||||
{syntheticReviewGroup && syntheticReviewGroup.fields.length > 0 && (
|
||||
<ConfigGroupCard
|
||||
group={syntheticReviewGroup}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
renderField={renderReviewField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine !== 'codex' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
<RoleAssignment />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WebhookToggleButtonProps {
|
||||
repoName: string;
|
||||
status: 'active' | 'inactive';
|
||||
hookId: number | null;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: status === 'active'
|
||||
? () => deleteWebhook({ repoName, hookId: hookId! })
|
||||
: () => createWebhook(repoName),
|
||||
onSuccess: () => {
|
||||
// 操作成功后,使仓库列表的查询失效,React Query会自动重新获取最新数据
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("操作失败:", error);
|
||||
toast.error(`Operation failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={status === 'active' ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className={
|
||||
status === 'active'
|
||||
? "border-rose-500/50 bg-transparent text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 transition-colors"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 hover:shadow-[0_0_15px_rgba(45,212,191,0.5)] tech-glow"
|
||||
}
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
<span className="font-mono text-xs">处理中...</span>
|
||||
</>
|
||||
) : status === 'active' ? (
|
||||
<span className="font-mono text-xs">停用</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs">启用</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface WebhookToggleCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) =>
|
||||
api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
|
||||
api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isActive = repo.webhook_status === 'active';
|
||||
|
||||
const webhookMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isActive && repo.hook_id) {
|
||||
return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
|
||||
}
|
||||
return createWebhook(repo.name);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
const action = isActive ? '已禁用' : '已启用';
|
||||
toast.success(`${repo.name} 的 Webhook ${action}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`操作失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{webhookMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => webhookMutation.mutate()}
|
||||
disabled={webhookMutation.isPending}
|
||||
aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-xs ${isActive ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RepositoryConfigCell } from '../RepositoryConfigCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RepositoryConfigCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens prompt dialog and saves project prompt', async () => {
|
||||
apiMocks.put.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
project_review_prompt: 'focus null safety',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RepositoryConfigCell repo={makeRepo()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /配置/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
await user.type(textarea, ' focus null safety ');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.put).toHaveBeenCalledWith(
|
||||
'/repositories/demo-owner/demo-repo/project-prompt',
|
||||
{ project_review_prompt: 'focus null safety' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReviewConfigPage } from '../ReviewConfigPage';
|
||||
import { fetchConfig, updateConfig, resetConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ProviderList', () => ({
|
||||
ProviderList: () => <div>ProviderListMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/RoleAssignment', () => ({
|
||||
RoleAssignment: () => <div>RoleAssignmentMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ModelCombobox', () => ({
|
||||
ModelCombobox: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<input aria-label="Codex model" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'GLOBAL_PROMPT',
|
||||
label: '全局提示词',
|
||||
description: '附加到所有 LLM 调用',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
value: '',
|
||||
hasValue: false,
|
||||
source: 'default',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: '/tmp/gitea-assistant',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
label: '最大并发数',
|
||||
description: '单机同时执行的审查任务上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '2',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
label: '允许命令',
|
||||
description: '本地审查沙箱命令白名单',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'git,rg,cat,sed,wc',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
label: '命令超时(ms)',
|
||||
description: '单条本地命令的执行超时时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
|
||||
label: 'Large 令牌预算',
|
||||
description: 'large 规模审查任务的 token 预算上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_MODEL',
|
||||
label: 'Codex 模型',
|
||||
description: 'Codex CLI 使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'o3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReviewConfigPage', () => {
|
||||
it('shows only current Agent config surface and saves only visible fields', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(resetConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<ReviewConfigPage />);
|
||||
|
||||
expect(await screen.findByText('Agent 审查设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('REVIEW_COMMAND_TIMEOUT_MS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_TOKEN_BUDGET_LARGE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_ENABLE_HUMAN_GATE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ENABLE_TRIAGE')).not.toBeInTheDocument();
|
||||
|
||||
const workdirInput = screen.getByDisplayValue('/tmp/gitea-assistant');
|
||||
await user.clear(workdirInput);
|
||||
await user.type(workdirInput, '/tmp/new-review-workdir');
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload.REVIEW_WORKDIR).toBe('/tmp/new-review-workdir');
|
||||
expect(payload.REVIEW_ENGINE).toBe('agent');
|
||||
expect(payload.REVIEW_COMMAND_TIMEOUT_MS).toBe('120000');
|
||||
expect(payload).not.toHaveProperty('REVIEW_TOKEN_BUDGET_LARGE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_ENABLE_HUMAN_GATE');
|
||||
expect(payload).not.toHaveProperty('ENABLE_TRIAGE');
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WebhookToggleCell } from '../WebhookToggleCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebhookToggleCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to enable', async () => {
|
||||
apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo()} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByText('未启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to disable', async () => {
|
||||
apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo({ webhook_status: 'active', hook_id: 123 })} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('已启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
|
||||
});
|
||||
});
|
||||
});
|
||||
14
frontend/src/components/llm/LLMProviders.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import { ProviderList } from './ProviderList';
|
||||
import { RoleAssignment } from './RoleAssignment';
|
||||
|
||||
export function LLMProviders() {
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
|
||||
<ProviderList />
|
||||
<RoleAssignment />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/llm/ModelCombobox.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchModelSuggestions } from '@/services/llmProviderService';
|
||||
import type { ProviderType } from '@/services/llmProviderService';
|
||||
|
||||
interface ModelComboboxProps {
|
||||
providerType?: ProviderType;
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelCombobox({
|
||||
providerType,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '选择或输入模型...',
|
||||
className = '',
|
||||
}: ModelComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync external value
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
|
||||
// Fetch dynamic model suggestions from backend (powered by models.dev)
|
||||
const { data: suggestions = {} } = useQuery({
|
||||
queryKey: ['llm-model-suggestions'],
|
||||
queryFn: fetchModelSuggestions,
|
||||
staleTime: 30 * 60 * 1000, // 30 min cache
|
||||
});
|
||||
|
||||
// Build model list: suggestions > custom input
|
||||
const suggestionModels = providerType ? suggestions[providerType] || [] : [];
|
||||
|
||||
type TaggedModel = { name: string; tag: '推荐' | '自定义' };
|
||||
|
||||
const trimmedInput = inputValue.trim().toLowerCase();
|
||||
|
||||
const buildTaggedList = (): TaggedModel[] => {
|
||||
const result: TaggedModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const m of suggestionModels) {
|
||||
if (!seen.has(m.toLowerCase()) && m.toLowerCase().includes(trimmedInput)) {
|
||||
result.push({ name: m, tag: '推荐' });
|
||||
seen.add(m.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Custom input option when no exact match
|
||||
if (inputValue.trim().length > 0 && !seen.has(trimmedInput)) {
|
||||
result.push({ name: inputValue.trim(), tag: '自定义' });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const taggedModels = buildTaggedList();
|
||||
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
'推荐': 'bg-info/15 text-info',
|
||||
'自定义': 'bg-warning/15 text-warning',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleSelect = (model: string) => {
|
||||
setInputValue(model);
|
||||
onChange(model);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={wrapperRef}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="bg-muted/50 border-border text-foreground w-full pr-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && taggedModels.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-popover border border-border rounded-lg shadow-xl">
|
||||
<div className="py-1">
|
||||
{taggedModels.map((item, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.tag}-${item.name}-${idx}`}
|
||||
className="w-full px-3 py-2 text-sm text-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none cursor-pointer transition-colors flex items-center justify-between gap-2"
|
||||
onClick={() => handleSelect(item.name)}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${TAG_STYLES[item.tag]}`}>{item.tag}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/llm/ProviderDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { createProvider, updateProvider, setApiKey } from '@/services/llmProviderService';
|
||||
import type { ProviderDto, ProviderType } from '@/services/llmProviderService';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
interface ProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider?: ProviderDto;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
|
||||
{ value: 'openai_compatible', label: 'OpenAI 兼容', description: '兼容 OpenAI 接口的第三方服务' },
|
||||
{ value: 'openai_responses', label: 'OpenAI Responses', description: 'OpenAI 官方 Responses API' },
|
||||
{ value: 'anthropic', label: 'Anthropic', description: 'Anthropic Messages API' },
|
||||
{ value: 'gemini', label: 'Gemini', description: 'Google Gemini API' },
|
||||
];
|
||||
|
||||
export function ProviderDialog({ open, onOpenChange, provider }: ProviderDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
// Inner component mounts fresh each time dialog opens,
|
||||
// so useState initializers read directly from provider props.
|
||||
return <ProviderDialogInner onOpenChange={onOpenChange} provider={provider} />;
|
||||
}
|
||||
|
||||
function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProps, 'open'>) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!provider;
|
||||
|
||||
const [name, setName] = useState(provider?.name ?? '');
|
||||
const [type, setType] = useState<ProviderType>(provider?.type ?? 'openai_compatible');
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '');
|
||||
const [defaultModel, setDefaultModel] = useState(provider?.defaultModel ?? '');
|
||||
const [apiKey, setApiKeyInput] = useState('');
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let savedProvider: ProviderDto;
|
||||
|
||||
const payload: Partial<ProviderDto> & { apiKey?: string } = {
|
||||
name,
|
||||
type,
|
||||
baseUrl: baseUrl || null,
|
||||
defaultModel,
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
if (apiKey) payload.apiKey = apiKey;
|
||||
savedProvider = await createProvider(payload);
|
||||
} else {
|
||||
savedProvider = await updateProvider(provider.id, {
|
||||
name,
|
||||
type,
|
||||
baseUrl: baseUrl || null,
|
||||
defaultModel,
|
||||
});
|
||||
if (apiKey) {
|
||||
await setApiKey(provider.id, apiKey);
|
||||
}
|
||||
}
|
||||
return savedProvider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
toast.success(isEdit ? '提供商已更新' : '提供商已创建');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const isBaseUrlRequired = type === 'openai_compatible';
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name) return toast.error('请输入名称');
|
||||
if (!defaultModel) return toast.error('请输入默认模型');
|
||||
if (isBaseUrlRequired && !baseUrl) return toast.error('该类型必须填写 Base URL');
|
||||
if (!isEdit && !apiKey) return toast.error('创建提供商时必须提供 API Key');
|
||||
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center theme-surface-overlay backdrop-blur-sm">
|
||||
<div className="theme-dialog-panel">
|
||||
<div className="theme-dialog-header">
|
||||
<h2 className="text-xl font-bold text-foreground">{isEdit ? '编辑提供商' : '添加提供商'}</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="theme-dialog-body space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称 <span className="text-danger">*</span></Label>
|
||||
<Input id="name" value={name} onChange={e => setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-muted/50 border-border text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>类型 <span className="text-danger">*</span></Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as ProviderType)}>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{TYPE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} description={opt.description}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baseUrl">Base URL {isBaseUrlRequired ? <span className="text-danger">*</span> : <span className="text-muted-foreground">(可选)</span>}</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder={isBaseUrlRequired ? "https://api.openai.com/v1" : "留空以使用默认地址"}
|
||||
autoComplete="off"
|
||||
className="bg-muted/50 border-border text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultModel">默认模型 <span className="text-danger">*</span></Label>
|
||||
<ModelCombobox
|
||||
providerType={type}
|
||||
value={defaultModel}
|
||||
onChange={setDefaultModel}
|
||||
placeholder="如: gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key {!isEdit && <span className="text-danger">*</span>}</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKeyInput(e.target.value)}
|
||||
placeholder={isEdit && provider?.hasKey ? '•••••••• (输入以覆盖)' : 'sk-...'}
|
||||
autoComplete="off"
|
||||
className="bg-muted/50 border-border text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div className="theme-dialog-footer flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="border-border text-muted-foreground hover:text-foreground hover:bg-accent">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" onClick={handleSubmit} disabled={saveMutation.isPending} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
{saveMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
frontend/src/components/llm/ProviderList.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
|
||||
import { fetchProviders, updateProvider, deleteProvider, testProvider } from '@/services/llmProviderService';
|
||||
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
|
||||
import { ProviderDialog } from './ProviderDialog';
|
||||
import { TestResultDialog } from './TestResultDialog';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
openai_compatible: 'OpenAI 兼容',
|
||||
openai_responses: 'OpenAI Responses',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
openai_compatible: 'bg-success/10 text-success border-success/20',
|
||||
openai_responses: 'bg-info/10 text-info border-info/20',
|
||||
anthropic: 'bg-warning/10 text-warning border-warning/20',
|
||||
gemini: 'bg-primary/10 text-primary border-primary/20',
|
||||
};
|
||||
|
||||
export function ProviderList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<ProviderDto | undefined>(undefined);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [testProviderName, setTestProviderName] = useState<string>('');
|
||||
|
||||
const { data: providers = [], isLoading } = useQuery({
|
||||
queryKey: ['llm-providers'],
|
||||
queryFn: fetchProviders,
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
|
||||
return updateProvider(id, { isEnabled });
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['llm-providers'] });
|
||||
const previousProviders = queryClient.getQueryData<ProviderDto[]>(['llm-providers']);
|
||||
queryClient.setQueryData<ProviderDto[]>(['llm-providers'], old =>
|
||||
old?.map(p => p.id === variables.id ? { ...p, isEnabled: variables.isEnabled } : p)
|
||||
);
|
||||
return { previousProviders };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
queryClient.setQueryData(['llm-providers'], context?.previousProviders);
|
||||
toast.error('切换状态失败');
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProvider,
|
||||
onSuccess: () => {
|
||||
toast.success('已删除提供商');
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`删除失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggle = (provider: ProviderDto) => {
|
||||
toggleMutation.mutate({ id: provider.id, isEnabled: !provider.isEnabled });
|
||||
};
|
||||
|
||||
const handleDelete = (provider: ProviderDto) => {
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(provider.id);
|
||||
};
|
||||
|
||||
const handleTest = async (provider: ProviderDto) => {
|
||||
try {
|
||||
setTestingId(provider.id);
|
||||
const result = await testProvider(provider.id);
|
||||
setTestResult(result);
|
||||
setTestProviderName(provider.name);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error('测试请求失败', {
|
||||
description: err?.response?.data?.error || err.message
|
||||
});
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingProvider(undefined);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (provider: ProviderDto) => {
|
||||
setEditingProvider(provider);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
模型提供商
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理连接的 LLM API 服务及其访问密钥
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openAdd} className="bg-primary text-primary-foreground hover:bg-primary/90 theme-glow-primary transition-all">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加提供商
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="border-border/60 hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground font-medium h-12">名称</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12">类型</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12">默认模型</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-center">状态</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-center">启用</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow className="border-border/60 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : providers.length === 0 ? (
|
||||
<TableRow className="border-border/60 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
暂无提供商配置,请点击右上角添加。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
providers.map(provider => (
|
||||
<TableRow key={provider.id} className="border-border/60 hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{provider.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`font-normal ${TYPE_COLORS[provider.type] || 'text-muted-foreground'}`}>
|
||||
{TYPE_LABELS[provider.type] || provider.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground/90">
|
||||
<code className="bg-muted/60 px-1.5 py-0.5 rounded text-xs text-primary/80">
|
||||
{provider.defaultModel}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5" title={provider.hasKey ? '已配置 API Key' : '未配置 API Key'}>
|
||||
<span className={`w-2 h-2 rounded-full ${provider.hasKey ? 'bg-success theme-glow-success' : 'bg-muted-foreground/60'}`} />
|
||||
<span className="text-xs text-muted-foreground">{provider.hasKey ? '就绪' : '无 Key'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
checked={provider.isEnabled}
|
||||
onCheckedChange={() => handleToggle(provider)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleTest(provider)}
|
||||
disabled={testingId === provider.id || !provider.hasKey}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="测试连接"
|
||||
>
|
||||
{testingId === provider.id ? (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(provider)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(provider)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-danger hover:bg-danger/10 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProviderDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
provider={editingProvider}
|
||||
/>
|
||||
|
||||
<TestResultDialog
|
||||
open={!!testResult}
|
||||
onOpenChange={(open) => !open && setTestResult(null)}
|
||||
result={testResult}
|
||||
providerName={testProviderName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
230
frontend/src/components/llm/RoleAssignment.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchConfig, updateConfig } from '@/services/configService';
|
||||
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
'AGENT_MAIN_MODEL',
|
||||
'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
];
|
||||
|
||||
const fieldsMap = useMemo(() => {
|
||||
if (!data) return new Map<string, ConfigFieldDto>();
|
||||
const map = new Map<string, ConfigFieldDto>();
|
||||
data.groups.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
map.set(field.envKey, field);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialValues: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
if (field) {
|
||||
initialValues[key] = String(field.value ?? field.defaultValue ?? '');
|
||||
} else {
|
||||
initialValues[key] = '';
|
||||
}
|
||||
});
|
||||
setLocalValues(initialValues);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [data, fieldsMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('智能体模型设置已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
payload[key] = localValues[key] ?? '';
|
||||
});
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
加载配置中...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="theme-error-panel flex items-center gap-3 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const missingKeys = REQUIRED_KEYS.filter((key) => !fieldsMap.has(key));
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4 flex flex-row items-start justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理智能体运行时的主模型、子模型以及 LLM 调用弹性设置。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[100px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content space-y-6">
|
||||
{missingKeys.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-warning text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">部分配置项在系统中不可用:</span>
|
||||
<span className="font-mono text-xs">{missingKeys.join(', ')}</span>。这些设置将无法编辑或保存。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{REQUIRED_KEYS.map((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
const isAvailable = !!field;
|
||||
const label = field?.label || key;
|
||||
const description = field?.description || '系统未提供该配置项的描述。';
|
||||
const type = field?.type === 'number' ? 'number' : 'text';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col gap-2 p-4 rounded-lg border transition-colors ${
|
||||
isAvailable
|
||||
? 'border-border hover:bg-accent/20'
|
||||
: 'border-dashed border-muted bg-muted/10 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={key} className="text-base font-semibold text-foreground cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{!isAvailable && (
|
||||
<Badge variant="outline" className="border-danger/30 text-danger bg-danger/5">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'db' && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 tech-glow">
|
||||
已配置
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'default' && (
|
||||
<Badge variant="outline" className="border-border text-muted-foreground">
|
||||
默认值
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
type={type}
|
||||
value={localValues[key] ?? ''}
|
||||
onChange={(e) => handleFieldChange(key, e.target.value)}
|
||||
disabled={!isAvailable || saveMutation.isPending}
|
||||
placeholder={!isAvailable ? '配置项不可用' : `请输入 ${label}...`}
|
||||
className="bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/llm/TestResultDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TestResult } from '@/services/llmProviderService';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
interface TestResultDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
result: TestResult | null;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export function TestResultDialog({ open, onOpenChange, result, providerName }: TestResultDialogProps) {
|
||||
if (!open || !result) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center theme-surface-overlay backdrop-blur-sm">
|
||||
<div className="theme-dialog-panel">
|
||||
<div className="theme-dialog-header">
|
||||
<h2 className="text-xl font-bold text-foreground">测试结果 - {providerName}</h2>
|
||||
</div>
|
||||
|
||||
<div className="theme-dialog-body space-y-5">
|
||||
{result.success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-success">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
<span className="text-lg font-medium">连接成功</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-foreground/90">
|
||||
{result.latencyMs !== undefined && (
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">延迟:</span>
|
||||
<span>{result.latencyMs} ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.model && (
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">模型:</span>
|
||||
<span className="font-mono">{result.model}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.message && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="text-muted-foreground">AI 响应:</span>
|
||||
<div className="bg-muted/60 border border-border rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
{result.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-danger">
|
||||
<XCircle className="w-8 h-8" />
|
||||
<span className="text-lg font-medium">测试失败</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-foreground/90">
|
||||
{result.latencyMs !== undefined && (
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">延迟:</span>
|
||||
<span>{result.latencyMs} ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="text-muted-foreground">错误:</span>
|
||||
<div className="bg-danger/10 border border-danger/20 text-danger rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
{result.error || result.message || '未知错误'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="theme-dialog-footer flex justify-end">
|
||||
<Button type="button" onClick={() => onOpenChange(false)} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/llm/__tests__/LLMProviders.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LLMProviders } from '../LLMProviders';
|
||||
|
||||
vi.mock('../ProviderList', () => ({
|
||||
ProviderList: () => <div>提供商区域</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../RoleAssignment', () => ({
|
||||
RoleAssignment: () => <div>角色区域</div>,
|
||||
}));
|
||||
|
||||
describe('LLMProviders', () => {
|
||||
it('renders providers and roles sections', () => {
|
||||
render(<LLMProviders />);
|
||||
|
||||
expect(screen.getByText('提供商区域')).toBeInTheDocument();
|
||||
expect(screen.getByText('角色区域')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
69
frontend/src/components/llm/__tests__/ModelCombobox.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ModelCombobox } from '../ModelCombobox';
|
||||
|
||||
vi.mock('@/services/llmProviderService', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
|
||||
return {
|
||||
...actual,
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'deepseek-chat'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ModelCombobox', () => {
|
||||
it('shows 推荐 models matching providerType and supports custom input', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderWithQuery(
|
||||
<ModelCombobox providerType="openai_compatible" value="" onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('选择或输入模型...');
|
||||
await user.click(input);
|
||||
|
||||
expect((await screen.findAllByText('推荐')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'my-custom-model');
|
||||
|
||||
expect(await screen.findByText('自定义')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('my-custom-model'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith('my-custom-model');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows different models when providerType changes', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderWithQuery(
|
||||
<ModelCombobox providerType="anthropic" value="" onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('选择或输入模型...');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(await screen.findByText('claude-sonnet-4-20250514')).toBeInTheDocument();
|
||||
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
87
frontend/src/components/llm/__tests__/ProviderList.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import {
|
||||
fetchProviders,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
} from '@/services/llmProviderService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', () => ({
|
||||
fetchProviders: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ProviderList', () => {
|
||||
it('renders providers, enable states and hasKey indicators', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'OpenAI 官方',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: '本地兼容服务',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
defaultModel: 'qwen-plus',
|
||||
isEnabled: false,
|
||||
hasKey: false,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
vi.mocked(updateProvider).mockResolvedValue({} as never);
|
||||
vi.mocked(deleteProvider).mockResolvedValue(undefined);
|
||||
vi.mocked(testProvider).mockResolvedValue({ success: true });
|
||||
|
||||
renderWithQuery(<ProviderList />);
|
||||
|
||||
expect(await screen.findByText('模型提供商')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenAI 官方')).toBeInTheDocument();
|
||||
expect(await screen.findByText('本地兼容服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('OpenAI Responses')).toBeInTheDocument();
|
||||
expect(screen.getByText('OpenAI 兼容')).toBeInTheDocument();
|
||||
expect(screen.getByText('就绪')).toBeInTheDocument();
|
||||
expect(screen.getByText('无 Key')).toBeInTheDocument();
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
expect(switches).toHaveLength(2);
|
||||
expect(switches[0]).toHaveAttribute('data-state', 'checked');
|
||||
expect(switches[1]).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
const testButtons = screen.getAllByTitle('测试连接');
|
||||
expect(testButtons).toHaveLength(2);
|
||||
expect(testButtons[0]).toBeEnabled();
|
||||
expect(testButtons[1]).toBeDisabled();
|
||||
});
|
||||
});
|
||||
190
frontend/src/components/llm/__tests__/RoleAssignment.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RoleAssignment } from '../RoleAssignment';
|
||||
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
label: '默认子智能体模型',
|
||||
description: '子智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o-mini',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_MAX_ATTEMPTS',
|
||||
label: 'LLM 最大重试次数',
|
||||
description: 'LLM 调用失败时的最大重试次数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_BASE_DELAY_MS',
|
||||
label: 'LLM 重试基础延迟(ms)',
|
||||
description: 'LLM 调用失败重试的基础延迟时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '1000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders agent model settings and saves edits', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
// Wait for the fields to load and render
|
||||
expect(await screen.findByText('主智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('默认子智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大并发调用')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大重试次数')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 重试基础延迟(ms)')).toBeInTheDocument();
|
||||
|
||||
const legacyLabels = ['pla' + 'nner', 'speci' + 'alist', 'ju' + 'dge', '角色' + '分配'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(['/', 'llm', '/', 'roles'].join(''))).not.toBeInTheDocument();
|
||||
|
||||
const mainModelInput = screen.getByLabelText('主智能体模型');
|
||||
const subagentModelInput = screen.getByLabelText('默认子智能体模型');
|
||||
const maxCallsInput = screen.getByLabelText('LLM 最大并发调用');
|
||||
const retryAttemptsInput = screen.getByLabelText('LLM 最大重试次数');
|
||||
const retryDelayInput = screen.getByLabelText('LLM 重试基础延迟(ms)');
|
||||
|
||||
await user.clear(mainModelInput);
|
||||
await user.type(mainModelInput, 'claude-3-5-sonnet');
|
||||
|
||||
await user.clear(subagentModelInput);
|
||||
await user.type(subagentModelInput, 'claude-3-5-haiku');
|
||||
|
||||
await user.clear(maxCallsInput);
|
||||
await user.type(maxCallsInput, '8');
|
||||
|
||||
await user.clear(retryAttemptsInput);
|
||||
await user.type(retryAttemptsInput, '5');
|
||||
|
||||
await user.clear(retryDelayInput);
|
||||
await user.type(retryDelayInput, '2000');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存设置' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload).toEqual({
|
||||
AGENT_MAIN_MODEL: 'claude-3-5-sonnet',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'claude-3-5-haiku',
|
||||
LLM_MAX_CONCURRENT_CALLS: '8',
|
||||
LLM_RETRY_MAX_ATTEMPTS: '5',
|
||||
LLM_RETRY_BASE_DELAY_MS: '2000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders missing-field/unavailable state when fields are missing', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue({
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
// Wait for the warning to load and render
|
||||
expect(await screen.findByText('部分配置项在系统中不可用:')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_MAX_CONCURRENT_CALLS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_MAX_ATTEMPTS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_BASE_DELAY_MS')).toBeInTheDocument();
|
||||
|
||||
const subagentInput = screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(subagentInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TestResultDialog } from '../TestResultDialog';
|
||||
|
||||
describe('TestResultDialog', () => {
|
||||
it('renders success state with latency, model and message', () => {
|
||||
render(
|
||||
<TestResultDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providerName="DeepSeek"
|
||||
result={{
|
||||
success: true,
|
||||
latencyMs: 123,
|
||||
model: 'deepseek-chat',
|
||||
message: '连接已建立',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试结果 - DeepSeek')).toBeInTheDocument();
|
||||
expect(screen.getByText('连接成功')).toBeInTheDocument();
|
||||
expect(screen.getByText('延迟:')).toBeInTheDocument();
|
||||
expect(screen.getByText('123 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('模型:')).toBeInTheDocument();
|
||||
expect(screen.getByText('deepseek-chat')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI 响应:')).toBeInTheDocument();
|
||||
expect(screen.getByText('连接已建立')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state and closes via button', async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestResultDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
providerName="OpenAI"
|
||||
result={{
|
||||
success: false,
|
||||
latencyMs: 789,
|
||||
error: '认证失败',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试失败')).toBeInTheDocument();
|
||||
expect(screen.getByText('延迟:')).toBeInTheDocument();
|
||||
expect(screen.getByText('789 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('错误:')).toBeInTheDocument();
|
||||
expect(screen.getByText('认证失败')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
122
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -19,8 +19,9 @@ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.V
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
hideIndicator = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { hideIndicator?: boolean }) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
@@ -31,9 +32,11 @@ function SelectTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
{!hideIndicator && (
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
@@ -125,8 +128,9 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & { description?: string }) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
@@ -141,7 +145,10 @@ function SelectItem({
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && <span className="text-xs text-muted-foreground">{description}</span>}
|
||||
</div>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
58
frontend/src/hooks/useColorPalette.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export const COLOR_PALETTE_STORAGE_KEY = 'ui-color-palette';
|
||||
|
||||
export const COLOR_PALETTES = ['cobalt', 'zinc', 'nord', 'tokyo-night'] as const;
|
||||
|
||||
export type ColorPalette = (typeof COLOR_PALETTES)[number];
|
||||
|
||||
type ColorPaletteContextValue = {
|
||||
palette: ColorPalette;
|
||||
setPalette: (palette: ColorPalette) => void;
|
||||
};
|
||||
|
||||
const ColorPaletteContext = createContext<ColorPaletteContextValue | null>(null);
|
||||
|
||||
export const isColorPalette = (value: string): value is ColorPalette =>
|
||||
COLOR_PALETTES.includes(value as ColorPalette);
|
||||
|
||||
const resolveInitialPalette = (): ColorPalette => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'cobalt';
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
|
||||
if (stored && isColorPalette(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return 'cobalt';
|
||||
};
|
||||
|
||||
export const ColorPaletteProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [palette, setPalette] = useState<ColorPalette>(resolveInitialPalette);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-palette', palette);
|
||||
window.localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, palette);
|
||||
}, [palette]);
|
||||
|
||||
const value = useMemo<ColorPaletteContextValue>(
|
||||
() => ({
|
||||
palette,
|
||||
setPalette,
|
||||
}),
|
||||
[palette]
|
||||
);
|
||||
|
||||
return <ColorPaletteContext.Provider value={value}>{children}</ColorPaletteContext.Provider>;
|
||||
};
|
||||
|
||||
export const useColorPalette = () => {
|
||||
const context = useContext(ColorPaletteContext);
|
||||
if (!context) {
|
||||
throw new Error('useColorPalette must be used within ColorPaletteProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -6,48 +6,313 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: 240 10% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 180 100% 35%;
|
||||
--primary: 224 76% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 180 100% 35%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 220 18% 94%;
|
||||
--accent-foreground: 240 10% 8%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 180 100% 35%;
|
||||
--ring: 224 76% 52%;
|
||||
|
||||
--success: 160 84% 39%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 24 10% 10%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
--info: 214 89% 55%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
--surface-muted: 240 8% 94%;
|
||||
--surface-elevated: 0 0% 100%;
|
||||
--surface-overlay: 240 16% 14%;
|
||||
|
||||
--text-subtle: 240 4% 43%;
|
||||
--text-soft: 240 4% 58%;
|
||||
|
||||
--border-soft: 240 6% 84%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: 240 10% 4%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 6%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 6%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 175 90% 45%;
|
||||
--primary-foreground: 240 10% 4%;
|
||||
--primary: 224 88% 68%;
|
||||
--primary-foreground: 224 40% 12%;
|
||||
--secondary: 240 5% 15%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 5% 15%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
--accent: 175 90% 45%;
|
||||
--accent-foreground: 240 10% 4%;
|
||||
--accent: 240 6% 16%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5% 15%;
|
||||
--input: 240 5% 15%;
|
||||
--ring: 175 90% 45%;
|
||||
--ring: 224 88% 68%;
|
||||
|
||||
--success: 160 80% 46%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 39 92% 58%;
|
||||
--warning-foreground: 24 10% 10%;
|
||||
--danger: 0 80% 63%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
--info: 214 88% 65%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
--surface-muted: 240 6% 11%;
|
||||
--surface-elevated: 240 8% 14%;
|
||||
--surface-overlay: 240 5% 6%;
|
||||
|
||||
--text-subtle: 240 5% 72%;
|
||||
--text-soft: 240 5% 60%;
|
||||
--border-soft: 240 5% 21%;
|
||||
}
|
||||
|
||||
:root[data-palette='zinc'] {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--success: 142.1 76.2% 36.3%;
|
||||
--success-foreground: 355.7 100% 97.3%;
|
||||
--warning: 47.9 95.8% 53.1%;
|
||||
--warning-foreground: 26 83.3% 14.1%;
|
||||
--danger: 0 84.2% 60.2%;
|
||||
--danger-foreground: 0 0% 98%;
|
||||
--info: 221.2 83.2% 53.3%;
|
||||
--info-foreground: 210 40% 98%;
|
||||
|
||||
--surface-muted: 240 4.8% 95.9%;
|
||||
--surface-elevated: 0 0% 100%;
|
||||
--surface-overlay: 240 10% 3.9%;
|
||||
--text-subtle: 240 3.8% 46.1%;
|
||||
--text-soft: 240 5% 64.9%;
|
||||
--border-soft: 240 5.9% 84%;
|
||||
}
|
||||
|
||||
.dark[data-palette='zinc'] {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--success: 142.1 70.6% 45.3%;
|
||||
--success-foreground: 144.9 80.4% 10%;
|
||||
--warning: 47.9 95.8% 53.1%;
|
||||
--warning-foreground: 26 83.3% 14.1%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 98%;
|
||||
--info: 217.2 91.2% 59.8%;
|
||||
--info-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--surface-muted: 240 3.7% 15.9%;
|
||||
--surface-elevated: 240 3.7% 18%;
|
||||
--surface-overlay: 240 23% 9%;
|
||||
--text-subtle: 240 5% 64.9%;
|
||||
--text-soft: 240 5% 56%;
|
||||
--border-soft: 240 3.7% 24%;
|
||||
}
|
||||
|
||||
:root[data-palette='nord'] {
|
||||
--background: 220 13% 91%;
|
||||
--foreground: 220 13% 17%;
|
||||
--card: 220 13% 88%;
|
||||
--card-foreground: 220 13% 17%;
|
||||
--popover: 220 13% 91%;
|
||||
--popover-foreground: 220 13% 17%;
|
||||
--primary: 204 48% 68%;
|
||||
--primary-foreground: 220 13% 17%;
|
||||
--secondary: 213 31% 48%;
|
||||
--secondary-foreground: 220 13% 91%;
|
||||
--muted: 220 13% 83%;
|
||||
--muted-foreground: 220 13% 35%;
|
||||
--accent: 192 34% 64%;
|
||||
--accent-foreground: 220 13% 17%;
|
||||
--destructive: 353 50% 57%;
|
||||
--destructive-foreground: 220 13% 91%;
|
||||
--border: 220 13% 75%;
|
||||
--input: 220 13% 75%;
|
||||
--ring: 204 48% 68%;
|
||||
|
||||
--success: 136 44% 64%;
|
||||
--success-foreground: 220 13% 17%;
|
||||
--warning: 43 74% 73%;
|
||||
--warning-foreground: 220 13% 17%;
|
||||
--danger: 353 50% 57%;
|
||||
--danger-foreground: 220 13% 91%;
|
||||
--info: 222 63% 70%;
|
||||
--info-foreground: 220 13% 17%;
|
||||
|
||||
--surface-muted: 220 13% 83%;
|
||||
--surface-elevated: 220 13% 88%;
|
||||
--surface-overlay: 220 13% 17%;
|
||||
--text-subtle: 220 13% 35%;
|
||||
--text-soft: 220 13% 45%;
|
||||
--border-soft: 220 13% 80%;
|
||||
}
|
||||
|
||||
.dark[data-palette='nord'] {
|
||||
--background: 220 13% 12%;
|
||||
--foreground: 220 13% 91%;
|
||||
--card: 220 13% 16%;
|
||||
--card-foreground: 220 13% 91%;
|
||||
--popover: 220 13% 16%;
|
||||
--popover-foreground: 220 13% 91%;
|
||||
--primary: 204 48% 68%;
|
||||
--primary-foreground: 220 13% 12%;
|
||||
--secondary: 213 31% 48%;
|
||||
--secondary-foreground: 220 13% 91%;
|
||||
--muted: 220 13% 24%;
|
||||
--muted-foreground: 220 13% 70%;
|
||||
--accent: 192 34% 64%;
|
||||
--accent-foreground: 220 13% 91%;
|
||||
--destructive: 353 50% 57%;
|
||||
--destructive-foreground: 220 13% 91%;
|
||||
--border: 220 13% 28%;
|
||||
--input: 220 13% 28%;
|
||||
--ring: 204 48% 68%;
|
||||
|
||||
--success: 136 44% 64%;
|
||||
--success-foreground: 220 13% 12%;
|
||||
--warning: 43 74% 73%;
|
||||
--warning-foreground: 220 13% 12%;
|
||||
--danger: 353 50% 57%;
|
||||
--danger-foreground: 220 13% 91%;
|
||||
--info: 222 63% 70%;
|
||||
--info-foreground: 220 13% 12%;
|
||||
|
||||
--surface-muted: 220 13% 24%;
|
||||
--surface-elevated: 220 13% 16%;
|
||||
--surface-overlay: 220 13% 12%;
|
||||
--text-subtle: 220 13% 70%;
|
||||
--text-soft: 220 13% 60%;
|
||||
--border-soft: 220 13% 20%;
|
||||
}
|
||||
|
||||
:root[data-palette='tokyo-night'] {
|
||||
--background: 230 15% 95%;
|
||||
--foreground: 230 20% 15%;
|
||||
--card: 230 15% 92%;
|
||||
--card-foreground: 230 20% 15%;
|
||||
--popover: 230 15% 92%;
|
||||
--popover-foreground: 230 20% 15%;
|
||||
--primary: 219 89% 72%;
|
||||
--primary-foreground: 230 20% 15%;
|
||||
--secondary: 268 89% 78%;
|
||||
--secondary-foreground: 230 20% 15%;
|
||||
--muted: 230 10% 85%;
|
||||
--muted-foreground: 230 10% 45%;
|
||||
--accent: 195 100% 74%;
|
||||
--accent-foreground: 230 20% 15%;
|
||||
--destructive: 343 91% 73%;
|
||||
--destructive-foreground: 230 15% 98%;
|
||||
--border: 230 10% 80%;
|
||||
--input: 230 10% 80%;
|
||||
--ring: 219 89% 72%;
|
||||
|
||||
--success: 80 78% 62%;
|
||||
--success-foreground: 230 20% 15%;
|
||||
--warning: 35 85% 66%;
|
||||
--warning-foreground: 230 20% 15%;
|
||||
--danger: 343 91% 73%;
|
||||
--danger-foreground: 230 15% 98%;
|
||||
--info: 195 100% 74%;
|
||||
--info-foreground: 230 20% 15%;
|
||||
|
||||
--surface-muted: 230 10% 85%;
|
||||
--surface-elevated: 230 15% 92%;
|
||||
--surface-overlay: 230 20% 15%;
|
||||
--text-subtle: 230 10% 45%;
|
||||
--text-soft: 230 10% 56%;
|
||||
--border-soft: 230 10% 85%;
|
||||
}
|
||||
|
||||
.dark[data-palette='tokyo-night'] {
|
||||
--background: 232 23% 10%;
|
||||
--foreground: 219 28% 88%;
|
||||
--card: 232 20% 14%;
|
||||
--card-foreground: 219 28% 88%;
|
||||
--popover: 232 20% 14%;
|
||||
--popover-foreground: 219 28% 88%;
|
||||
--primary: 219 89% 72%;
|
||||
--primary-foreground: 232 23% 10%;
|
||||
--secondary: 268 89% 78%;
|
||||
--secondary-foreground: 232 23% 10%;
|
||||
--muted: 232 20% 20%;
|
||||
--muted-foreground: 219 28% 60%;
|
||||
--accent: 195 100% 74%;
|
||||
--accent-foreground: 232 23% 10%;
|
||||
--destructive: 343 91% 73%;
|
||||
--destructive-foreground: 219 28% 88%;
|
||||
--border: 232 20% 25%;
|
||||
--input: 232 20% 25%;
|
||||
--ring: 219 89% 72%;
|
||||
|
||||
--success: 80 78% 62%;
|
||||
--success-foreground: 232 23% 10%;
|
||||
--warning: 35 85% 66%;
|
||||
--warning-foreground: 232 23% 10%;
|
||||
--danger: 343 91% 73%;
|
||||
--danger-foreground: 219 28% 88%;
|
||||
--info: 195 100% 74%;
|
||||
--info-foreground: 232 23% 10%;
|
||||
|
||||
--surface-muted: 232 20% 20%;
|
||||
--surface-elevated: 232 20% 14%;
|
||||
--surface-overlay: 232 23% 10%;
|
||||
--text-subtle: 219 28% 60%;
|
||||
--text-soft: 219 28% 52%;
|
||||
--border-soft: 232 20% 18%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,16 +337,155 @@
|
||||
}
|
||||
.bg-grid-pattern {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
background-image: linear-gradient(to right, hsl(var(--foreground) / 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--foreground) / 0.05) 1px, transparent 1px);
|
||||
}
|
||||
.glass-panel {
|
||||
@apply bg-zinc-950/50 backdrop-blur-xl border border-white/10 shadow-2xl;
|
||||
@apply bg-card/80 backdrop-blur-xl border border-border shadow-2xl;
|
||||
}
|
||||
|
||||
.theme-surface-muted {
|
||||
background: hsl(var(--surface-muted));
|
||||
}
|
||||
|
||||
.theme-surface-elevated {
|
||||
background: hsl(var(--surface-elevated));
|
||||
}
|
||||
|
||||
.theme-surface-overlay {
|
||||
background: hsl(var(--surface-overlay) / 0.62);
|
||||
}
|
||||
|
||||
.theme-border-soft {
|
||||
border-color: hsl(var(--border-soft));
|
||||
}
|
||||
|
||||
.theme-text-subtle {
|
||||
color: hsl(var(--text-subtle));
|
||||
}
|
||||
|
||||
.theme-text-soft {
|
||||
color: hsl(var(--text-soft));
|
||||
}
|
||||
|
||||
.theme-glow-primary {
|
||||
box-shadow: 0 0 18px -6px hsl(var(--primary) / 0.45);
|
||||
}
|
||||
|
||||
.theme-glow-success {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--success) / 0.55);
|
||||
}
|
||||
|
||||
.theme-glow-warning {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--warning) / 0.55);
|
||||
}
|
||||
|
||||
.theme-glow-danger {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--danger) / 0.55);
|
||||
}
|
||||
|
||||
.theme-shell-gradient {
|
||||
background-image:
|
||||
radial-gradient(circle at 12% 8%, hsl(var(--primary) / 0.1), transparent 34%),
|
||||
radial-gradient(circle at 92% 82%, hsl(var(--accent) / 0.12), transparent 38%),
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.98), hsl(var(--background)) 40%);
|
||||
}
|
||||
|
||||
.theme-control-pill {
|
||||
@apply inline-flex items-center gap-2 rounded-full border border-border/70 bg-muted/55 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur;
|
||||
}
|
||||
|
||||
.theme-input-surface {
|
||||
@apply bg-muted/45 border-border/70 text-foreground placeholder:text-muted-foreground/70 focus-visible:border-primary/40 focus-visible:ring-primary/20;
|
||||
}
|
||||
|
||||
.theme-interactive-elevate {
|
||||
transition: transform 180ms ease, box-shadow 220ms ease, border-color 220ms ease;
|
||||
}
|
||||
|
||||
.theme-interactive-elevate:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 35px -30px hsl(var(--foreground) / 0.8);
|
||||
border-color: hsl(var(--border-soft));
|
||||
}
|
||||
|
||||
.theme-sticky-bar {
|
||||
@apply backdrop-blur-2xl border-b border-border/70;
|
||||
background: hsl(var(--background) / 0.88);
|
||||
box-shadow: 0 12px 30px -26px hsl(var(--foreground) / 0.55);
|
||||
}
|
||||
|
||||
.theme-sidebar-shell {
|
||||
@apply border-r border-border/70 backdrop-blur-xl;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--surface-elevated) / 0.9) 0%, hsl(var(--background) / 0.86) 100%);
|
||||
box-shadow: inset -1px 0 0 hsl(var(--border-soft) / 0.35);
|
||||
}
|
||||
|
||||
.theme-sidebar-header {
|
||||
@apply border-b border-border/60;
|
||||
background: hsl(var(--surface-elevated) / 0.9);
|
||||
}
|
||||
|
||||
.theme-sidebar-footer {
|
||||
@apply border-t border-border/55;
|
||||
background: hsl(var(--background) / 0.78);
|
||||
}
|
||||
|
||||
.theme-page-frame {
|
||||
@apply min-h-screen pb-12;
|
||||
}
|
||||
|
||||
.theme-page-actions {
|
||||
@apply flex flex-wrap items-center justify-end gap-3 max-w-6xl mx-auto;
|
||||
}
|
||||
|
||||
.theme-page-content {
|
||||
@apply max-w-6xl mx-auto mt-7 space-y-8 px-4 md:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.theme-card-shell {
|
||||
@apply relative overflow-hidden rounded-2xl border border-border/70 backdrop-blur-xl;
|
||||
background: hsl(var(--card) / 0.88);
|
||||
box-shadow: 0 20px 50px -42px hsl(var(--foreground) / 0.85);
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
@apply border-b border-border/60 bg-muted/35 px-6 py-5;
|
||||
}
|
||||
|
||||
.theme-card-content {
|
||||
@apply p-6 bg-background/35;
|
||||
}
|
||||
|
||||
.theme-error-panel {
|
||||
@apply glass-panel p-4 rounded-lg text-danger border border-danger/20 bg-danger/10;
|
||||
}
|
||||
|
||||
.theme-dialog-panel {
|
||||
@apply glass-panel w-full max-w-md bg-card border border-border rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh];
|
||||
}
|
||||
|
||||
.theme-dialog-header {
|
||||
@apply px-6 py-4 border-b border-border;
|
||||
}
|
||||
|
||||
.theme-dialog-body {
|
||||
@apply px-6 py-5 overflow-y-auto flex-1;
|
||||
}
|
||||
|
||||
.theme-dialog-footer {
|
||||
@apply px-6 py-4 border-t border-border bg-muted/40;
|
||||
}
|
||||
|
||||
.theme-spin-reverse-slow {
|
||||
animation: spin 1.5s linear infinite reverse;
|
||||
}
|
||||
|
||||
.tech-glow {
|
||||
box-shadow: 0 0 20px -5px hsl(var(--primary) / 0.5);
|
||||
box-shadow: 0 0 18px -8px hsl(var(--foreground) / 0.2);
|
||||
}
|
||||
.tech-glow:hover {
|
||||
box-shadow: 0 0 30px -5px hsl(var(--primary) / 0.7);
|
||||
box-shadow: 0 0 24px -8px hsl(var(--foreground) / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,20 @@ api.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// 添加响应拦截器,处理 401 未授权自动跳转登录页
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
// 避免在登录接口本身触发跳转
|
||||
const isLoginRequest = error.config?.url?.includes('/login');
|
||||
if (!isLoginRequest) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -21,13 +22,13 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
// Force dark mode as requested
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Layers } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||
{ path: '/config', label: '配置管理', icon: Sliders },
|
||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||
{ path: '/review-runs', label: '审查任务', icon: Layers },
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const { palette, setPalette } = useColorPalette();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -25,30 +33,32 @@ export default function DashboardPage() {
|
||||
|
||||
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
||||
const isConfigPage = location.pathname.startsWith('/config');
|
||||
const isNotificationPage = location.pathname.startsWith('/notifications');
|
||||
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<div className="theme-shell-gradient flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in"
|
||||
className="fixed inset-0 z-40 theme-surface-overlay backdrop-blur-md lg:hidden animate-in fade-in"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-zinc-950 transition-all duration-300 ease-in-out lg:relative ${
|
||||
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
|
||||
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
className={`theme-sidebar-shell fixed inset-y-0 left-0 z-50 flex flex-col transition-all duration-300 ease-in-out ${
|
||||
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
|
||||
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-border/50 bg-zinc-950">
|
||||
<div className="theme-sidebar-header flex h-16 items-center justify-between px-4">
|
||||
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isSidebarCollapsed ? 'w-10 justify-center -ml-1' : 'w-full'}`}>
|
||||
<div className="flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-[0_0_15px_rgba(20,184,166,0.15)] ring-1 ring-primary/10">
|
||||
<div className="theme-interactive-elevate flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 theme-glow-primary ring-1 ring-primary/10">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="truncate font-bold tracking-tight text-zinc-100 whitespace-nowrap">
|
||||
<span className="truncate font-bold tracking-tight text-foreground whitespace-nowrap">
|
||||
Gitea AI Assistant
|
||||
</span>
|
||||
)}
|
||||
@@ -56,7 +66,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden shrink-0 h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
className="lg:hidden shrink-0 h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -71,20 +81,20 @@ export default function DashboardPage() {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex w-full items-center rounded-xl p-2.5 transition-all duration-200 ${
|
||||
`group relative flex w-full items-center rounded-xl border p-2.5 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100'
|
||||
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
|
||||
}
|
||||
? 'border-primary/[0.14] bg-primary/[0.08] text-primary shadow-sm'
|
||||
: 'border-transparent text-muted-foreground hover:bg-accent/50 hover:border-border/60 hover:text-foreground'
|
||||
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
|
||||
}
|
||||
title={isSidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary shadow-[0_0_10px_rgba(20,184,166,0.5)]"></div>
|
||||
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary theme-glow-primary"></div>
|
||||
)}
|
||||
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-zinc-500 group-hover:text-zinc-300'}`} />
|
||||
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-muted-foreground group-hover:text-foreground'}`} />
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="font-medium tracking-wide text-sm">{item.label}</span>
|
||||
)}
|
||||
@@ -95,10 +105,10 @@ export default function DashboardPage() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border/50 p-3 bg-zinc-950">
|
||||
<div className="theme-sidebar-footer p-3">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className={`hidden lg:flex w-full items-center rounded-xl p-2.5 text-zinc-500 transition-colors hover:bg-zinc-900 hover:text-zinc-300 ${
|
||||
className={`hidden lg:flex w-full items-center rounded-xl border border-transparent p-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:border-border/60 hover:text-foreground ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'
|
||||
}`}
|
||||
>
|
||||
@@ -115,37 +125,76 @@ export default function DashboardPage() {
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
<div
|
||||
className={`relative flex flex-1 flex-col overflow-hidden transition-[margin] duration-300 ease-in-out ${
|
||||
isSidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64'
|
||||
}`}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border/50 bg-background/80 px-4 backdrop-blur-md z-10">
|
||||
<header className="theme-sticky-bar flex h-16 shrink-0 items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-zinc-400 hover:text-zinc-100 h-9 w-9 -ml-2"
|
||||
className="lg:hidden text-muted-foreground hover:text-foreground h-9 w-9 -ml-2"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block shadow-[0_0_8px_rgba(20,184,166,0.4)]"></div>
|
||||
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block theme-glow-primary"></div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground">{currentTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full border border-border/50 bg-zinc-900/50">
|
||||
<div className="theme-control-pill hidden sm:flex">
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-success"></span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">System Online</span>
|
||||
<span className="font-mono uppercase tracking-wider">System Online</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border/50 hidden sm:block"></div>
|
||||
<div className="hidden md:flex items-center">
|
||||
<Select
|
||||
value={palette}
|
||||
onValueChange={(value) => {
|
||||
if (isColorPalette(value)) {
|
||||
setPalette(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9 p-0 justify-center [&>span]:hidden"
|
||||
title="切换配色方案"
|
||||
aria-label="切换配色方案"
|
||||
hideIndicator
|
||||
>
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cobalt" description="默认 · 钴蓝冷静">Cobalt Blue</SelectItem>
|
||||
<SelectItem value="zinc" description="shadcn · 中性灰阶">Zinc Neutral</SelectItem>
|
||||
<SelectItem value="nord" description="Nord · Arctic Blue">Nord</SelectItem>
|
||||
<SelectItem value="tokyo-night" description="Tokyo Night · Neon Indigo">Tokyo Night</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full border border-border/50 bg-zinc-900 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/20 transition-all h-9 w-9"
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9"
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
title={resolvedTheme === 'dark' ? '切换为浅色主题' : '切换为深色主题'}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="sr-only">切换主题</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-danger/10 hover:text-danger hover:border-danger/30 transition-all h-9 w-9"
|
||||
onClick={handleLogout}
|
||||
title="登出"
|
||||
>
|
||||
@@ -158,8 +207,8 @@ export default function DashboardPage() {
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto relative">
|
||||
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] -z-10"></div>
|
||||
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${isConfigPage ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -z-10"></div>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isNotificationPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LoginPage() {
|
||||
} else {
|
||||
setError('登录失败,返回的 token 为空。');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('登录失败,请检查密码是否正确或查看服务日志。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -29,28 +29,28 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-zinc-950">
|
||||
<div className="theme-shell-gradient relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-background">
|
||||
{/* Background grid and gradient effects */}
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-10"></div>
|
||||
<div className="absolute top-[-20%] left-[-10%] h-[500px] w-[500px] rounded-full bg-primary/20 blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-20%] right-[-10%] h-[500px] w-[500px] rounded-full bg-primary/10 blur-[100px] pointer-events-none"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.04]"></div>
|
||||
<div className="absolute top-[-25%] left-[-14%] h-[460px] w-[460px] rounded-full bg-primary/14 blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-26%] right-[-12%] h-[460px] w-[460px] rounded-full bg-accent/20 blur-[120px] pointer-events-none"></div>
|
||||
|
||||
<div className="z-10 w-full max-w-md px-4 sm:px-6 relative">
|
||||
<div className="glass-panel relative rounded-2xl p-8 sm:p-10 transition-all duration-500 hover:border-primary/20">
|
||||
<div className="theme-card-shell theme-interactive-elevate relative p-8 sm:p-10">
|
||||
{/* Decorative terminal dots */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-rose-500/80 shadow-[0_0_5px_rgba(244,63,94,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-amber-500/80 shadow-[0_0_5px_rgba(245,158,11,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/80 shadow-[0_0_5px_rgba(16,185,129,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-danger/80 theme-glow-danger"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-warning/80 theme-glow-warning"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-success/80 theme-glow-success"></div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 mt-6 flex flex-col items-center text-center">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-zinc-900 border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.15)] ring-1 ring-primary/10 relative group">
|
||||
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/70 border border-primary/20 theme-glow-primary ring-1 ring-primary/10 relative group">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/80 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<Bot className="h-8 w-8 text-primary relative z-10" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-white sm:text-3xl">Gitea AI Assistant</h1>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-primary/70 bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">Gitea AI Assistant</h1>
|
||||
<div className="theme-control-pill text-primary/80">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
@@ -62,7 +62,7 @@ export function LoginPage() {
|
||||
<div className="grid gap-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="text-xs font-mono font-medium text-zinc-400 flex items-center gap-2">
|
||||
<label htmlFor="password" className="text-xs font-mono font-medium text-muted-foreground flex items-center gap-2">
|
||||
<span className="text-primary font-bold">></span> enter_admin_password
|
||||
</label>
|
||||
</div>
|
||||
@@ -75,14 +75,14 @@ export function LoginPage() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleLogin()}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="h-12 border-zinc-800 bg-zinc-900/50 font-mono text-zinc-100 placeholder:text-zinc-700 focus-visible:border-primary/50 focus-visible:ring-primary/20 transition-all duration-300"
|
||||
className="theme-input-surface h-12 font-mono placeholder:text-muted-foreground/50 transition-all duration-300"
|
||||
/>
|
||||
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600 transition-colors group-focus-within:text-primary/70" />
|
||||
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary/70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-3 text-sm text-rose-400 animate-in fade-in slide-in-from-top-1">
|
||||
<div className="theme-error-panel flex items-start gap-2 px-3 py-3 text-sm animate-in fade-in slide-in-from-top-1">
|
||||
<Activity className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="font-mono text-xs leading-relaxed">{error}</p>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ export function LoginPage() {
|
||||
className="tech-glow group relative mt-4 h-12 w-full overflow-hidden bg-primary text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-70 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full w-full justify-center [transform:skew(-12deg)_translateX(-150%)] group-hover:duration-1000 group-hover:[transform:skew(-12deg)_translateX(150%)]">
|
||||
<div className="relative h-full w-12 bg-white/20"></div>
|
||||
<div className="relative h-full w-12 bg-foreground/20"></div>
|
||||
</div>
|
||||
<span className="relative flex items-center gap-2 font-mono font-semibold tracking-wide">
|
||||
{isLoading ? (
|
||||
|
||||
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
import type { AgentSessionTree } from '@/services/reviewSessionService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
|
||||
ChevronRight, ChevronDown, Clock, FileText, Layers,
|
||||
AlertTriangle, CornerDownRight, HelpCircle, Info
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Components & Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return <Badge className="bg-success/20 text-success border-success/30">成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30">失败</Badge>;
|
||||
case 'running':
|
||||
case 'in_progress':
|
||||
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse">运行中</Badge>;
|
||||
case 'queued':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30">排队中</Badge>;
|
||||
case 'ignored':
|
||||
return <Badge className="bg-muted text-muted-foreground border-border">已忽略</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold">高</Badge>;
|
||||
case 'medium':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold">中</Badge>;
|
||||
case 'low':
|
||||
return <Badge className="bg-info/20 text-info border-info/30 font-bold">低</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{severity}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString?: string): string {
|
||||
if (!isoString) return '-';
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Tree Node Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeNodeProps {
|
||||
session: AgentSessionTree;
|
||||
level: number;
|
||||
onSelectSession: (session: AgentSessionTree) => void;
|
||||
selectedSessionId?: string;
|
||||
}
|
||||
|
||||
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
|
||||
const isSelected = selectedSessionId === session.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Node Row */}
|
||||
<div
|
||||
onClick={() => onSelectSession(session)}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground truncate">
|
||||
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{session.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={session.status} />
|
||||
{session.error && (
|
||||
<div title="代理执行出错">
|
||||
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && session.invocations && (
|
||||
<div className="flex flex-col w-full">
|
||||
{session.invocations.map((inv) => {
|
||||
if (inv.childSession) {
|
||||
return (
|
||||
<AgentTreeNode
|
||||
key={inv.childSession.id}
|
||||
session={inv.childSession}
|
||||
level={level + 1}
|
||||
onSelectSession={onSelectSession}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
);
|
||||
} else if (inv.status === 'failed') {
|
||||
// Failed subagent invocation without child session
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
|
||||
style={{ marginLeft: `${(level + 1) * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<CornerDownRight className="w-4 h-4 text-danger/50" />
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-danger/10 text-danger">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-danger truncate">
|
||||
子代理启动失败: {inv.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-danger/80 font-mono truncate">
|
||||
{inv.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status="failed" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Detail Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DetailPanelProps {
|
||||
session: AgentSessionTree;
|
||||
}
|
||||
|
||||
function AgentDetailPanel({ session }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
{session.parentSessionId ? '子代理详情' : '主代理详情'}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
|
||||
ID: {session.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">代理类型</span>
|
||||
<span className="font-semibold text-foreground">{session.agentType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">运行模型</span>
|
||||
<span className="font-mono font-semibold text-foreground">{session.model}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">启动时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">结束时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.error && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">执行错误</div>
|
||||
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'messages' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1.5" />
|
||||
消息记录 ({session.messages?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'tools' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
工具调用 ({session.toolCalls?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'raw' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('raw')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5 mr-1.5" />
|
||||
元数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{activeTab === 'messages' && (
|
||||
<div className="space-y-4">
|
||||
{session.messages && session.messages.length > 0 ? (
|
||||
session.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col p-3 rounded-xl border ${
|
||||
msg.role === 'user'
|
||||
? 'border-primary/20 bg-primary/5 ml-8'
|
||||
: msg.role === 'assistant'
|
||||
? 'border-border bg-muted/40 mr-8'
|
||||
: 'border-warning/20 bg-warning/5 mx-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
|
||||
}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDateTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
|
||||
? String(msg.content.text)
|
||||
: JSON.stringify(msg.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消息记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-4">
|
||||
{session.toolCalls && session.toolCalls.length > 0 ? (
|
||||
session.toolCalls.map((tool) => (
|
||||
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
|
||||
</div>
|
||||
<StatusBadge status={tool.status} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3 text-xs font-mono">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">参数 (Arguments)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{tool.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">结果 (Result)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.error && (
|
||||
<div>
|
||||
<div className="text-danger mb-1">错误 (Error)</div>
|
||||
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无工具调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'raw' && (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">元数据 (Metadata)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{session.finalResult !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">最终结果 (Final Result)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.finalResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
|
||||
|
||||
// Fetch runs list
|
||||
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
|
||||
queryKey: ['reviewRuns'],
|
||||
queryFn: () => fetchReviewRuns(50),
|
||||
});
|
||||
|
||||
// Fetch selected run details
|
||||
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
|
||||
queryKey: ['reviewRunDetails', selectedRunId],
|
||||
queryFn: () => fetchReviewRunDetails(selectedRunId!),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const runs = runsData?.data ?? [];
|
||||
|
||||
// Handle run selection
|
||||
const handleSelectRun = (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setSelectedSession(null); // Reset selected session when switching runs
|
||||
};
|
||||
|
||||
// Automatically select first run if none selected
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
|
||||
// Automatically select root session when run details load
|
||||
if (runDetails?.sessionTree && !selectedSession) {
|
||||
setSelectedSession(runDetails.sessionTree);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Runs List */}
|
||||
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 shrink-0">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
审查任务列表
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">展示最近 50 次自动审查任务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/60" />
|
||||
<Skeleton className="h-3 w-1/2 bg-muted/60" />
|
||||
</div>
|
||||
))
|
||||
) : isListError ? (
|
||||
<div className="theme-error-panel flex items-center gap-2 p-4">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<span className="text-sm font-medium">加载列表失败: {listError.message}</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
暂无审查任务记录
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
onClick={() => handleSelectRun(run.id)}
|
||||
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/5 theme-glow-primary'
|
||||
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-bold text-sm text-foreground truncate flex-1">
|
||||
{run.owner}/{run.repo}
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
|
||||
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span>尝试: {run.attempts}/{run.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Content: Run Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{selectedRunId ? (
|
||||
isDetailsLoading ? (
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/3 bg-muted/60" />
|
||||
<Skeleton className="h-4 w-1/4 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
) : isDetailsError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="theme-error-panel flex items-center gap-3 max-w-md">
|
||||
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold text-foreground">加载详情失败</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !runDetails ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
未找到任务详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
{runDetails.run.owner}/{runDetails.run.repo}
|
||||
</h1>
|
||||
<StatusBadge status={runDetails.run.status} />
|
||||
<Badge variant="outline" className="border-border/60">
|
||||
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
任务 ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">创建时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
|
||||
</div>
|
||||
{runDetails.run.finishedAt && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">完成时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runDetails.run.error && (
|
||||
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold">任务执行失败:</span> {runDetails.run.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
|
||||
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
|
||||
<TabsTrigger
|
||||
value="observability"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
代理观测 (Observability)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="findings"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
审查结果 ({runDetails.findings?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="log"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
运行日志 ({runDetails.steps?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Observability */}
|
||||
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
|
||||
{runDetails.sessionTree ? (
|
||||
<>
|
||||
{/* Left: Session Tree */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
代理调用树 (Parent-Child Tree)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AgentTreeNode
|
||||
session={runDetails.sessionTree}
|
||||
level={0}
|
||||
onSelectSession={(session) => setSelectedSession(session)}
|
||||
selectedSessionId={selectedSession?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Selected Session Detail */}
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
{selectedSession ? (
|
||||
<AgentDetailPanel session={selectedSession} />
|
||||
) : (
|
||||
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
|
||||
<p className="text-sm font-medium">请在左侧选择一个代理节点查看详细调用轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
|
||||
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium">本次审查任务未使用 Agent 引擎,或暂无代理调用轨迹数据</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">请确保系统配置中已启用 Agent 审查引擎</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Findings */}
|
||||
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
|
||||
{runDetails.findings && runDetails.findings.length > 0 ? (
|
||||
runDetails.findings.map((finding) => (
|
||||
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
|
||||
{finding.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{finding.path}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold text-foreground tracking-tight">
|
||||
{finding.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
置信度: {(finding.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">详细描述</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
|
||||
</div>
|
||||
{finding.evidence && (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">代码证据</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{finding.suggestion && (
|
||||
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
|
||||
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
修改建议
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
本次审查未发现任何问题
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Run Log */}
|
||||
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
|
||||
{/* Steps */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
执行步骤 (Steps)
|
||||
</h3>
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
|
||||
<th className="p-3">步骤名称</th>
|
||||
<th className="p-3">状态</th>
|
||||
<th className="p-3">耗时</th>
|
||||
<th className="p-3">开始时间</th>
|
||||
<th className="p-3">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{runDetails.steps && runDetails.steps.length > 0 ? (
|
||||
runDetails.steps.map((step) => (
|
||||
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
|
||||
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
暂无步骤记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
评论记录 (Comments)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{runDetails.comments && runDetails.comments.length > 0 ? (
|
||||
runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.path && (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{comment.path}:{comment.line}
|
||||
</span>
|
||||
)}
|
||||
{comment.giteaCommentId && (
|
||||
<Badge variant="outline" className="text-[10px] border-border/60">
|
||||
Gitea ID: {comment.giteaCommentId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={comment.status} />
|
||||
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
暂无评论记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
|
||||
<h3 className="text-lg font-bold text-foreground">请选择一个审查任务</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">在左侧列表中选择一个任务以查看其详细的代理调用轨迹和审查结果</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ReviewSessionsPage from '../ReviewSessionsPage';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
|
||||
vi.mock('@/services/reviewSessionService', () => ({
|
||||
fetchReviewRuns: vi.fn(),
|
||||
fetchReviewRunDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ReviewSessionsPage', () => {
|
||||
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 42,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: {},
|
||||
createdAt: '2026-05-25T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
toolName: 'search_code',
|
||||
status: 'completed',
|
||||
arguments: {},
|
||||
createdAt: '2026-05-25T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-1',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-1',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-1',
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:15.000Z',
|
||||
completedAt: '2026-05-25T00:00:30.000Z',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
updatedAt: '2026-05-25T00:00:30.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-2',
|
||||
sequence: 2,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-2',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-2',
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:35.000Z',
|
||||
completedAt: '2026-05-25T00:00:50.000Z',
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
updatedAt: '2026-05-25T00:00:50.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const mainAgentText = await screen.findByText('主代理: review-main-agent');
|
||||
expect(mainAgentText).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert subagents are rendered
|
||||
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert tool calls count is visible in the details panel tabs
|
||||
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-2',
|
||||
idempotencyKey: 'key-2',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'failed' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 43,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
runId: 'run-2',
|
||||
fingerprint: 'fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/db.ts',
|
||||
line: 10,
|
||||
title: 'SQL Injection vulnerability',
|
||||
detail: 'Direct string concatenation in query',
|
||||
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-2',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'failed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-failed',
|
||||
parentSessionId: 'session-main-2',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'failed',
|
||||
input: {},
|
||||
error: 'Failed to initialize subagent',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
|
||||
expect(failedSubagentText).toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Switch to findings tab
|
||||
const findingsTab = screen.getByText('审查结果 (1)');
|
||||
expect(findingsTab).toBeInTheDocument();
|
||||
await user.click(findingsTab);
|
||||
|
||||
// Assert finding title still renders
|
||||
const findingTitle = await screen.findByText('SQL Injection vulnerability');
|
||||
expect(findingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 3: asserts no legacy review labels are visible', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-3',
|
||||
idempotencyKey: 'key-3',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 44,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-3',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText('分流')).toBeNull();
|
||||
expect(screen.queryByText('专家')).toBeNull();
|
||||
expect(screen.queryByText('裁判')).toBeNull();
|
||||
expect(screen.queryByText('规划')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ConfigSource = 'default' | 'env' | 'override';
|
||||
export type ConfigSource = 'default' | 'db';
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
export interface ConfigFieldDto {
|
||||
@@ -9,8 +9,6 @@ export interface ConfigFieldDto {
|
||||
description: string;
|
||||
type: ConfigFieldType;
|
||||
sensitive: boolean;
|
||||
readonly?: boolean;
|
||||
readonlyWarning?: string;
|
||||
enumValues?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -32,6 +30,21 @@ export interface ConfigResponse {
|
||||
groups: ConfigGroupDto[];
|
||||
}
|
||||
|
||||
export type NotificationTestProvider = 'feishu' | 'wecom';
|
||||
export type NotificationTestStatus = 'success' | 'error';
|
||||
|
||||
export interface NotificationTestRecordDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: NotificationTestStatus;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationTestHistoryResponse {
|
||||
data: NotificationTestRecordDto[];
|
||||
}
|
||||
|
||||
export const fetchConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await api.get<ConfigResponse>('/config');
|
||||
return response.data;
|
||||
@@ -44,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
|
||||
export const resetConfig = async (keys: string[]): Promise<void> => {
|
||||
await api.post('/config/reset', { keys });
|
||||
};
|
||||
|
||||
export const testNotification = async (provider: NotificationTestProvider): Promise<void> => {
|
||||
await api.post('/config/notification/test', { provider });
|
||||
};
|
||||
|
||||
export const fetchNotificationTestHistory = async (): Promise<NotificationTestRecordDto[]> => {
|
||||
const response = await api.get<NotificationTestHistoryResponse>('/config/notification/test/history');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
73
frontend/src/services/llmProviderService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ProviderType = 'openai_compatible' | 'openai_responses' | 'anthropic' | 'gemini';
|
||||
|
||||
export interface ProviderDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
baseUrl: string | null;
|
||||
defaultModel: string;
|
||||
isEnabled: boolean;
|
||||
hasKey: boolean;
|
||||
extraConfig: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Fallback suggestions when API is unavailable (e.g. catalog not loaded yet). */
|
||||
const FALLBACK_SUGGESTIONS: Record<ProviderType, string[]> = {
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'deepseek-chat'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
|
||||
};
|
||||
|
||||
export const fetchModelSuggestions = async (): Promise<Record<string, string[]>> => {
|
||||
try {
|
||||
const response = await api.get<Record<string, string[]>>('/llm/model-suggestions');
|
||||
return response.data;
|
||||
} catch {
|
||||
return FALLBACK_SUGGESTIONS;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchProviders = async (): Promise<ProviderDto[]> => {
|
||||
const response = await api.get<ProviderDto[]>('/llm/providers');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createProvider = async (data: Partial<ProviderDto> & { apiKey?: string }): Promise<ProviderDto> => {
|
||||
const response = await api.post<ProviderDto>('/llm/providers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProvider = async (id: string, data: Partial<ProviderDto>): Promise<ProviderDto> => {
|
||||
const response = await api.put<ProviderDto>(`/llm/providers/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProvider = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}`);
|
||||
};
|
||||
|
||||
export const setApiKey = async (id: string, apiKey: string): Promise<void> => {
|
||||
await api.put(`/llm/providers/${id}/key`, { apiKey });
|
||||
};
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}/key`);
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export interface Repository {
|
||||
name: string;
|
||||
webhook_status: 'active' | 'inactive';
|
||||
hook_id: number | null;
|
||||
project_review_prompt: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedRepositories {
|
||||
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRepositoryProjectPrompt = async (
|
||||
repoName: string,
|
||||
projectReviewPrompt: string
|
||||
): Promise<{ success: boolean; project_review_prompt: string | null }> => {
|
||||
const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
|
||||
project_review_prompt: projectReviewPrompt,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
147
frontend/src/services/reviewSessionService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export interface ReviewRun {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
eventType: 'pull_request' | 'commit_status';
|
||||
status: ReviewRunStatus;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string;
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStep {
|
||||
id: string;
|
||||
runId: string;
|
||||
stepName: string;
|
||||
agentName?: string;
|
||||
status: 'started' | 'succeeded' | 'failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
latencyMs?: number;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
runId: string;
|
||||
fingerprint: string;
|
||||
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
confidence: number;
|
||||
path: string;
|
||||
line: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewCommentRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
giteaCommentId?: number;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: any;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
arguments: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
input: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
metadata: Record<string, any>;
|
||||
finalResult?: any;
|
||||
error?: any;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface ReviewRunDetails {
|
||||
run: ReviewRun;
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
sessionTree?: AgentSessionTree | null;
|
||||
}
|
||||
|
||||
export const fetchReviewRuns = async (limit: number = 50): Promise<{ data: ReviewRun[] }> => {
|
||||
const response = await api.get<{ data: ReviewRun[] }>('/review/runs', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
|
||||
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
|
||||
return response.data;
|
||||
};
|
||||
7
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -34,6 +34,22 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: "hsl(var(--danger))",
|
||||
foreground: "hsl(var(--danger-foreground))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
info: {
|
||||
DEFAULT: "hsl(var(--info))",
|
||||
foreground: "hsl(var(--info-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
|
||||
71
frontend/tests/visual/app.visual.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installVisualApiMocks } from './fixtures/mockApi';
|
||||
import { applyThemeAndAuth, installVisualNetworkGuards, stabilizeVisualState, waitForThemeReady, type VisualPalette } from './fixtures/stabilize';
|
||||
|
||||
type VisualCase = {
|
||||
name: string;
|
||||
path: string;
|
||||
authToken?: string;
|
||||
readySelectors: string[];
|
||||
};
|
||||
|
||||
const protectedToken = 'visual-token';
|
||||
|
||||
const visualCases: VisualCase[] = [
|
||||
{
|
||||
name: 'login',
|
||||
path: '/',
|
||||
readySelectors: ['#password', 'button:has-text("AUTHORIZE")'],
|
||||
},
|
||||
{
|
||||
name: 'repos',
|
||||
path: '/repos',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['text=仓库名称', 'text=demo-repo-1'],
|
||||
},
|
||||
{
|
||||
name: 'config',
|
||||
path: '/config',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['button:has-text("保存配置")', 'text=Gitea 连接'],
|
||||
},
|
||||
{
|
||||
name: 'review-config',
|
||||
path: '/review-config',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['text=审查引擎', 'button:has-text("保存配置")'],
|
||||
},
|
||||
];
|
||||
|
||||
const themes: Array<'light' | 'dark'> = ['light', 'dark'];
|
||||
const palettes: VisualPalette[] = ['cobalt', 'zinc', 'nord', 'tokyo-night'];
|
||||
|
||||
for (const visualCase of visualCases) {
|
||||
for (const theme of themes) {
|
||||
for (const palette of palettes) {
|
||||
test(`${visualCase.name} ${theme} ${palette} baseline`, async ({ page }) => {
|
||||
await installVisualApiMocks(page);
|
||||
await installVisualNetworkGuards(page);
|
||||
await applyThemeAndAuth(page, theme, palette, visualCase.authToken);
|
||||
|
||||
await page.goto(visualCase.path, { waitUntil: 'networkidle' });
|
||||
await waitForThemeReady(page, theme, palette);
|
||||
|
||||
for (const selector of visualCase.readySelectors) {
|
||||
await page.locator(selector).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
await stabilizeVisualState(page);
|
||||
|
||||
const snapshotName =
|
||||
palette === 'cobalt'
|
||||
? `${visualCase.name}-${theme}.png`
|
||||
: `${visualCase.name}-${theme}-${palette}.png`;
|
||||
|
||||
await expect(page).toHaveScreenshot(snapshotName, {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 151 KiB |