mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-12 23:16:49 +00:00
Compare commits
108 Commits
v1.0.0
...
opencode/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fac1f6942 | ||
|
|
45fcf2eaa1 | ||
|
|
d48eee3474 | ||
|
|
c0de9238b5 | ||
|
|
aa8d4ab072 | ||
|
|
1831704644 | ||
|
|
f0e45a5ae5 | ||
|
|
0ad83a4082 | ||
|
|
eeb209dbaf | ||
|
|
e1d8c1b7d2 | ||
|
|
6d62b9f87c | ||
|
|
bcc9e7b8eb | ||
|
|
12e1f4717b | ||
|
|
6ca9edecfd | ||
|
|
c4cbced8af | ||
|
|
e0ab3019db | ||
|
|
cd2bdf4131 | ||
|
|
b304814e42 | ||
|
|
1ff629cffb | ||
|
|
8ccc7452e5 | ||
|
|
b2b914f919 | ||
|
|
7b9b9e69a7 | ||
|
|
46c5e09a62 | ||
|
|
1a43b1f206 | ||
|
|
1b26fac951 | ||
|
|
38e4c58d71 | ||
|
|
5b29e2d4af | ||
|
|
ac40957ede | ||
|
|
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
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 进行配置。
|
||||
|
||||
83
.github/workflows/ci.yml
vendored
83
.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,79 @@ 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/
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: test
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
ports: ['3333:3000']
|
||||
env:
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__server__ROOT_URL: http://localhost:3333
|
||||
GITEA__security__INSTALL_LOCK: true
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: '*'
|
||||
GITEA__webhook__SKIP_TLS_VERIFY: true
|
||||
options: >-
|
||||
--health-cmd "curl -f http://localhost:3000/api/v1/version"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
- name: Create Gitea admin user
|
||||
run: |
|
||||
for i in $(seq 1 10); do
|
||||
if docker exec $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null; then
|
||||
echo "User created"
|
||||
break
|
||||
fi
|
||||
echo "Retrying... ($i)"
|
||||
sleep 3
|
||||
done || true
|
||||
docker exec -u git $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null || true
|
||||
|
||||
- name: Run E2E tests
|
||||
run: bun run test:e2e
|
||||
env:
|
||||
E2E_GITEA_URL: http://localhost:3333
|
||||
E2E_MOCK_LLM: 1
|
||||
|
||||
49
.github/workflows/release.yml
vendored
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
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -4,3 +4,17 @@ 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
|
||||
|
||||
4
.husky/pre-commit
Executable file
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
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
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 && 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
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 staged 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` (staged tasks) 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
|
||||
|
||||
|
||||
235
bun.lock
235
bun.lock
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"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",
|
||||
@@ -11,6 +14,8 @@
|
||||
"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 +24,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 +63,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,13 +91,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/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.23.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
@@ -108,16 +147,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 +193,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 +243,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 +261,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 +273,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 +311,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 +329,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 +363,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 +385,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 +399,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 +437,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 +481,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 +507,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 +531,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 +559,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 +581,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 +591,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 +621,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 +659,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 +679,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 +707,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 +717,27 @@
|
||||
|
||||
"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 +769,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 +787,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 +809,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 +817,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 +841,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 +1001,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 +1185,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 +1207,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 +1221,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 +1247,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 +1271,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 +1281,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
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
root = "./src"
|
||||
@@ -1,14 +1,8 @@
|
||||
version: '3.8'
|
||||
|
||||
# E2E 测试环境:Gitea + gitea-assistant
|
||||
# 用法:
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# # 等待服务启动后运行 seed 脚本:
|
||||
# ./e2e/seed.sh
|
||||
# # 运行 E2E 测试:
|
||||
# ./e2e/test.sh
|
||||
# # 清理:
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# ./e2e/seed.sh
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
|
||||
services:
|
||||
gitea:
|
||||
@@ -46,22 +40,18 @@ 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
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
- REVIEW_ENGINE=agent
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=5752fac0e57d00e9b7954863faef878693420e6b06bc20d710897587e802668a
|
||||
- REVIEW_ENGINE=kernel
|
||||
- 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
|
||||
- DATABASE_PATH=/data/assistant.db
|
||||
- E2E_MOCK_LLM=1
|
||||
ports:
|
||||
- "3334:3000"
|
||||
- "3334:5174"
|
||||
volumes:
|
||||
- assistant-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@@ -69,3 +59,4 @@ services:
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
assistant-data:
|
||||
|
||||
@@ -8,15 +8,20 @@ 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
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
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 +37,31 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6333/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
driver: local
|
||||
assistant_data:
|
||||
driver: local
|
||||
|
||||
22
docs/README.md
Normal file
22
docs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 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
|
||||
|
||||
- [Pluggable LLM providers](./design/pluggable-llm-providers.md)
|
||||
- [Kernel built-in Agent architecture](./design/kernel-built-in-agents.md)
|
||||
- [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,26 @@
|
||||
# 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 签名验证
|
||||
- [可插拔 LLM 提供商设计](./design/pluggable-llm-providers.md)
|
||||
- [Kernel 内置 Agent 架构设计](./design/kernel-built-in-agents.md)
|
||||
- [通知服务重构设计](./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
0
docs/assets/.gitkeep
Normal file
BIN
docs/assets/page-config.png
Normal file
BIN
docs/assets/page-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/assets/page-notifications.png
Normal file
BIN
docs/assets/page-notifications.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/assets/page-repos.png
Normal file
BIN
docs/assets/page-repos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/page-review-config.png
Normal file
BIN
docs/assets/page-review-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
78
docs/configuration.md
Normal file
78
docs/configuration.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
- Role mapping: planner, specialist, judge, embedding
|
||||
|
||||
## 4) Notification
|
||||
|
||||
- Feishu webhook and optional secret
|
||||
- WeCom (企业微信) webhook
|
||||
|
||||
## 5) Review
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage switch
|
||||
- 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
|
||||
|
||||
## 6) Memory & learning (optional)
|
||||
|
||||
- `ENABLE_MEMORY` (default `false`)
|
||||
- Qdrant URL
|
||||
- Reflection/debate toggles
|
||||
|
||||
Qdrant is only required when memory is enabled.
|
||||
78
docs/configuration.zh-CN.md
Normal file
78
docs/configuration.zh-CN.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 配置参考
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 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
|
||||
- 角色模型:planner、specialist、judge、embedding
|
||||
|
||||
## 4) 通知
|
||||
|
||||
- Feishu Webhook 与可选签名密钥
|
||||
- WeCom(企业微信)Webhook
|
||||
|
||||
## 5) 审查
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 开关
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
|
||||
> 规模与模式是两个层次:
|
||||
>
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
|
||||
## 6) 记忆与学习(可选)
|
||||
|
||||
- `ENABLE_MEMORY`(默认 `false`)
|
||||
- Qdrant URL
|
||||
- Reflection / Debate 开关
|
||||
|
||||
仅在开启记忆能力时需要 Qdrant。
|
||||
64
docs/deployment.md
Normal file
64
docs/deployment.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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 both:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
If you do not use memory features, Qdrant can be optional in custom compose setups.
|
||||
|
||||
## 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/qdrant.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"}}'
|
||||
```
|
||||
64
docs/deployment.zh-CN.md
Normal file
64
docs/deployment.zh-CN.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 部署指南
|
||||
|
||||
## 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`
|
||||
- `qdrant`
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
|
||||
|
||||
## 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/qdrant.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"}}'
|
||||
```
|
||||
890
docs/design/kernel-built-in-agents.md
Normal file
890
docs/design/kernel-built-in-agents.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 技术设计文档:Kernel 内置 Agent 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-04-28
|
||||
> **相关模块**: `src/agent-kernel/`、`src/review/kernel/`
|
||||
> **适用范围**: Review Kernel 的内置 subagent 体系、运行时委派、管理后台可观测能力与生产测试门禁
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 文档信息](#0-文档信息)
|
||||
- [1. 背景与目标](#1-背景与目标)
|
||||
- [2. 设计原则与关键取舍](#2-设计原则与关键取舍)
|
||||
- [3. 概要设计](#3-概要设计)
|
||||
- [4. 内置 Agent 详细设计](#4-内置-agent-详细设计)
|
||||
- [4.8 Agent工作机制详解](#48-agent工作机制详解)
|
||||
- [5. 运行时与状态设计](#5-运行时与状态设计)
|
||||
- [6. API 与管理后台可观测性](#6-api-与管理后台可观测性)
|
||||
- [7. 非功能性设计](#7-非功能性设计)
|
||||
- [8. 测试与上线验证](#8-测试与上线验证)
|
||||
- [9. 风险、待确认与后续演进](#9-风险待确认与后续演进)
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档信息
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 版本 | v0.1 |
|
||||
| 状态 | 草案 |
|
||||
| 目标读者 | 研发 / 架构 / QA / 运维 / 管理后台开发 |
|
||||
| 系统类型 | AI 应用工程 / 后端 Agent Runtime / 审查系统适配层 |
|
||||
| 主要代码路径 | `src/agent-kernel/`、`src/review/kernel/` |
|
||||
| 相关配置 | `REVIEW_ENGINE=kernel` |
|
||||
|
||||
### Assumptions
|
||||
|
||||
- 当前项目已选择 **kernel-first** 作为代码审查主路径;旧固定 agent 编排不作为未来运行时主路径。
|
||||
- 内置 Agent 当前以 **built-in subagent definition** 的方式注册,后续可演进到 plugin/custom subagent 加载。
|
||||
- 一条 PR 对应一个 kernel session,commit 更新、人工反馈和后续恢复都写入同一 session。
|
||||
|
||||
### To Be Confirmed
|
||||
|
||||
- 是否需要把 built-in subagent 的定义从 TypeScript 代码进一步外置为 YAML/JSON/插件目录。
|
||||
- 管理后台是否需要支持逐 subagent 的启用/禁用、版本选择与灰度策略。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
早期审查系统采用固定流程编排:triage 后按审查域派生多个 specialist,再由额外阶段汇总。该方案的问题是:
|
||||
|
||||
- 流程扩展需要修改 orchestrator/runtime 代码;
|
||||
- 角色能力与执行链路耦合,难以按能力标签选择代理;
|
||||
- 缺少独立 subagent identity、delegation boundary 和 invocation trace;
|
||||
- 管理后台难以展示“有哪些 Agent、何时被调用、产生了什么结果”;
|
||||
- 恢复、压缩、权限、hook 等横切能力难以统一接入。
|
||||
|
||||
新的 Kernel 内置 Agent 架构将 review 角色转换为注册式 built-in subagents,由 `AgentKernelRunner` 根据 planner 输出与 session state 推进任务,并通过 `KernelAgentInvoker` 统一委派执行。
|
||||
|
||||
### 1.2 核心目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|---|---|
|
||||
| 注册式扩展 | 内置 Agent 以 `KernelSubagentDefinition` 注册,runtime 不硬编码角色实例 |
|
||||
| 能力选择 | planner 通过 tags/capabilities 选择 subagent,而不是写死 agent id |
|
||||
| 可恢复执行 | session checkpoint 持久化 state + pendingTasks,支持 feedback 后继续执行 |
|
||||
| 委派边界 | 每次 subagent 调用都有 agentId、delegation packet、invocation record、structured result |
|
||||
| 上下文压缩 | 大上下文触发 compression,summary 写入 checkpoint 并回注后续 subagent |
|
||||
| 工具治理 | 工具调用走统一 orchestration、permission gating 与 hooks |
|
||||
| 可观测性 | 管理 API 暴露 task/subagent/hook catalog、session timeline、subagent invocations |
|
||||
|
||||
### 1.3 范围与非范围
|
||||
|
||||
**范围内**:
|
||||
|
||||
- Review Kernel 内置 subagents 的定义、职责、标签、运行链路;
|
||||
- Kernel agent registry / invoker / runner 与 session checkpoint 的协作;
|
||||
- 内置 Agent 与 tools、hooks、permission、compression 的集成方式;
|
||||
- 管理后台需要消费的 catalog 与 session 投影视图;
|
||||
- 生产前自动化测试门禁。
|
||||
|
||||
**范围外**:
|
||||
|
||||
- 前端 UI 视觉设计细节;
|
||||
- 旧 `agent` 固定编排引擎兼容;
|
||||
- Codex CLI 引擎内部实现;
|
||||
- 通用插件市场、远程 agent 执行后端和多租户权限模型。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则与关键取舍
|
||||
|
||||
### 2.1 核心设计原则
|
||||
|
||||
| 原则 | 落地方式 |
|
||||
|---|---|
|
||||
| 高内聚低耦合 | `src/agent-kernel/` 只提供通用 session/runner/registry/invoker/hooks;review 逻辑放在 `src/review/kernel/` |
|
||||
| 开闭原则 | 新增流程能力优先增加 subagent、skill、hook 或 tool,而不是修改主循环 |
|
||||
| Session 为状态源 | PR/commit session 记录 event、checkpoint、subagent invocation,是恢复与投影的事实来源 |
|
||||
| 可观测优先 | 每次 subagent 调用持久化 invocation;每个 task 写入 started/completed/failed event |
|
||||
| 安全默认 | 工具执行统一经过 permission gating;高风险 scope 默认 ask/deny |
|
||||
| 可测试 | 断言面落在 checkpoint、events、invocations、tool result、admin projection,而不是完整 LLM 文本 |
|
||||
|
||||
### 2.2 关键取舍
|
||||
|
||||
| 取舍点 | 选择 | 原因 |
|
||||
|---|---|---|
|
||||
| 内置 Agent 表达方式 | TypeScript built-in definitions | 当前阶段需要强类型、低迁移成本;后续可迁移到 plugin loader |
|
||||
| Agent 调用入口 | `KernelAgentInvoker` 统一调用 | 统一 agentId、hook、invocation persistence、structured result |
|
||||
| 流程推进方式 | planner + session state | 避免静态任务数组;支持继续执行与人审恢复 |
|
||||
| Findings 处理 | 本地归一化、去重、排序与发布 | full review 只产出 findings;后续由 skill/本地逻辑保证确定性 |
|
||||
| 压缩策略 | planner 模型窗口 80% 触发 | 使用 tokenlens context window,预留 20% 冗余 |
|
||||
| 管理接口 | task/subagent/hook catalog + session detail | 让后台可解释当前能力目录与执行轨迹 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 概要设计
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[(Kernel Session Repository)]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runtime --> SkillRegistry[KernelTaskRegistry / Skills]
|
||||
Runtime --> AgentRegistry[KernelAgentRegistry / Built-in Subagents]
|
||||
Runtime --> HookRegistry[KernelHookRegistry]
|
||||
Runtime --> ToolRegistry[ToolRegistry]
|
||||
|
||||
Runner --> Planner[State-driven Planner]
|
||||
Planner --> SkillTask[Skill Task]
|
||||
Planner --> SubagentTask[Subagent Task]
|
||||
|
||||
SkillTask --> SkillRegistry
|
||||
SubagentTask --> Invoker[KernelAgentInvoker]
|
||||
Invoker --> AgentContext[AsyncLocalStorage Agent Context]
|
||||
Invoker --> Invocation[(Subagent Invocation Record)]
|
||||
Invoker --> Builtins[Review Built-in Subagents]
|
||||
|
||||
Builtins --> Triage[review:triage]
|
||||
Builtins --> FullReview[review:full_review]
|
||||
|
||||
FullReview --> ToolOrchestration[Tool Orchestration]
|
||||
ToolOrchestration --> Permission[Permission Gating]
|
||||
ToolOrchestration --> Hooks[Pre/Post Tool Hooks]
|
||||
|
||||
Runtime --> AdminAPI[Admin API Catalog / Session Projection]
|
||||
```
|
||||
|
||||
### 3.2 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| Kernel types | `src/agent-kernel/types.ts` | 定义 task、subagent、delegation packet、checkpoint、invocation result |
|
||||
| Agent registry | `src/agent-kernel/agents/kernel-agent-registry.ts` | 注册、查询、按 tag 过滤 subagent |
|
||||
| Agent invoker | `src/agent-kernel/agents/kernel-agent-invoker.ts` | 创建 agentId、触发 hook、持久化 invocation、执行 subagent |
|
||||
| Agent context | `src/agent-kernel/agents/kernel-agent-context.ts` | 使用 AsyncLocalStorage 隔离子代理执行上下文 |
|
||||
| Runner | `src/agent-kernel/runtime/agent-kernel-runner.ts` | 按 planner 结果推进 skill/subagent task,写 checkpoint 与 task event |
|
||||
| Session repo | `src/agent-kernel/session/session-repository.ts` | 持久化 session、events、checkpoint、subagent invocations |
|
||||
| Review runtime | `src/review/kernel/review-kernel-runtime.ts` | 注册 skills/hooks/built-in subagents,提供 execute/continueExecution |
|
||||
| Built-in subagents | `src/review/kernel/review-built-in-subagents.ts` | 将 triage 与 full_review 转换为注册式 subagent definitions |
|
||||
| Subagent ids | `src/review/kernel/review-subagent-ids.ts` | 统一内置 subagent id 命名 |
|
||||
| Admin projection | `src/review/kernel/session-read-model.ts` | 将 session event/checkpoint/invocation 投影为后台视图 |
|
||||
|
||||
### 3.3 核心执行链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as KernelReviewEngine
|
||||
participant R as ReviewKernelRuntime
|
||||
participant S as SessionRepository
|
||||
participant K as AgentKernelRunner
|
||||
participant I as KernelAgentInvoker
|
||||
participant A as Built-in Subagent
|
||||
|
||||
E->>S: ensureSession(scopeKey)
|
||||
E->>R: execute(run, sessionId)
|
||||
R->>S: appendEvent(run_started)
|
||||
R->>K: run(initialState, initialTasks=[])
|
||||
loop until stopReason
|
||||
K->>K: planner.plan(state)
|
||||
alt skill task
|
||||
K->>R: execute skill handler
|
||||
else subagent task
|
||||
K->>I: invoke(task, context)
|
||||
I->>S: createSubagentInvocation(running)
|
||||
I->>A: execute(task, agentContext)
|
||||
A-->>I: KernelHandlerResult
|
||||
I->>S: completeSubagentInvocation(completed)
|
||||
end
|
||||
K->>S: appendEvent(task_completed)
|
||||
K->>S: saveCheckpoint(state, pendingTasks, stopReason)
|
||||
end
|
||||
R->>S: appendEvent(run_completed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 内置 Agent 详细设计
|
||||
|
||||
### 4.1 内置 Agent 目录
|
||||
|
||||
| Subagent ID | Source | Model Role | Tags | 职责 | 触发条件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `review:triage` | `built-in` | `planner` | `review`, `planner`, `triage` | 根据 diff、文件、风险生成自主审查提示、模式和预算 | build context 完成且尚无 triage 结果 |
|
||||
| `review:full_review` | `built-in` | `specialist` | `review`, `specialist`, `full-review`, `autonomous-review` | 执行一次完整自主代码审查,模型自行选择工具和调查路径 | triage 完成且尚未完成 full review |
|
||||
|
||||
### 4.2 Subagent Definition 契约
|
||||
|
||||
每个内置 Agent 必须实现 `KernelSubagentDefinition<TState>`:
|
||||
|
||||
```typescript
|
||||
interface KernelSubagentDefinition<TState> {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: 'built-in' | 'custom' | 'plugin';
|
||||
description: string;
|
||||
whenToUse: string;
|
||||
tags?: string[];
|
||||
modelRole?: string;
|
||||
resumable?: boolean;
|
||||
execute(task, context): Promise<KernelHandlerResult<TState> | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
关键约束:
|
||||
|
||||
- `name` 必须稳定,作为 session event、invocation、admin catalog 的统一标识;
|
||||
- `tags` 必须包含能力标签,planner 只能按 tag/capability 选择代理;
|
||||
- `whenToUse` 既用于管理后台解释,也用于 delegation packet 的 goal;
|
||||
- `execute` 不直接控制主循环,只返回 state/enqueue/prepend/stopReason;
|
||||
- 内置 Agent 不应越权直接修改 pendingTasks,除非通过标准 `KernelHandlerResult`。
|
||||
|
||||
### 4.3 Planner 选择规则
|
||||
|
||||
`ReviewKernelRuntime.planTasks()` 根据 checkpoint state 推导下一步:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[开始 plan] --> B{有 pendingTasks?}
|
||||
B -- 是 --> Z[不新增任务]
|
||||
B -- 否 --> C{缺 workspace?}
|
||||
C -- 是 --> PW[prepare_workspace skill]
|
||||
C -- 否 --> D{缺 context?}
|
||||
D -- 是 --> BC[build_context skill]
|
||||
D -- 否 --> E{需要压缩?}
|
||||
E -- 是 --> CC[compress_context skill]
|
||||
E -- 否 --> F{缺 triage?}
|
||||
F -- 是 --> T[按 tag=triage 选择 review:triage]
|
||||
F -- 否 --> G{full review 未完成?}
|
||||
G -- 是 --> S[执行 review:full_review]
|
||||
G -- 否 --> P{未 publish?}
|
||||
P -- 是 --> PR[publish_review skill]
|
||||
P -- 否 --> R{未保存 reviewed ref?}
|
||||
R -- 是 --> SR[save_reviewed_ref skill]
|
||||
R -- 否 --> DONE[completed]
|
||||
```
|
||||
|
||||
### 4.4 Triage Agent
|
||||
|
||||
`review:triage` 包装 `TriageAgent`,输出自主审查提示:
|
||||
|
||||
- 使用 `planner` 模型角色;
|
||||
- 接收 `projectPrompt` 和 `compressedContext.summary`;
|
||||
- 生成 `mode`、`reviewSize`、`riskTags`、`suspectedEntrypoints` 与预算提示;
|
||||
- 提示只影响 full review 的调查起点,不拆分审查任务。
|
||||
|
||||
### 4.5 Autonomous Full Review Agent
|
||||
|
||||
`review:full_review` 包装 `AutonomousReviewAgent`:
|
||||
|
||||
- 共享 `ToolRegistry` 与 `KernelHookRegistry`;
|
||||
- 根据 `ReviewTask` 控制 mode、reviewSize、riskTags、suspectedEntrypoints、maxTurns、maxToolCalls、maxElapsedMs、tokenBudget;
|
||||
- 支持压缩 summary 回注到 prompt;
|
||||
- 不预拆 correctness/security/quality 子任务,模型在一次自主循环内跨文件调查;
|
||||
- 工具调用统一经过 tool orchestration、permission gating、Pre/Post tool hooks。
|
||||
|
||||
### 4.6 Aggregate Findings Skill
|
||||
|
||||
`aggregate_findings` 是 full review 后的确定性本地步骤:
|
||||
|
||||
- 接收 `review:full_review` 产出的 findings;
|
||||
- 归一化 category/severity/confidence,补齐 fingerprint;
|
||||
- 按 fingerprint 去重,并按 severity/path/line/title 稳定排序;
|
||||
- 写回 checkpoint,供后续发布步骤使用。
|
||||
|
||||
### 4.7 Publish and Save Skills
|
||||
|
||||
`publish_review` 与 `save_reviewed_ref` 负责外部副作用:
|
||||
|
||||
- `publish_review` 生成确定性 summary,并发布 PR summary 与 line comments;
|
||||
- `save_reviewed_ref` 在本地 mirror 保存已审查 ref,用于后续增量审查;
|
||||
- 两个步骤分离,避免评论发布和 ref 保存互相污染,失败时依赖 checkpoint 重试。
|
||||
|
||||
---
|
||||
|
||||
## 4.8 Agent工作机制详解
|
||||
|
||||
本节详细说明 Kernel Agent 的运转机制、任务调度、工具调用、决策逻辑及边界划分。
|
||||
|
||||
### 4.8.1 核心运转架构
|
||||
|
||||
Kernel 采用「**事件驱动 + 状态机**」的运行模式:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[Session Repository]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runner --> Planner[Turn Planner]
|
||||
Planner --> Tasks[Tasks Queue]
|
||||
Tasks --> Executor[Task Executor]
|
||||
Executor --> State[State Update]
|
||||
State --> Checkpoint[Checkpoint Save]
|
||||
Checkpoint --> Runner
|
||||
```
|
||||
|
||||
**关键组件职责**:
|
||||
|
||||
| 组件 | 文件 | 核心职责 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | `agent-kernel-runner.ts` | 主循环控制器:任务调度、状态流转、checkpoint 管理 |
|
||||
| **ReviewKernelRuntime** | `review-kernel-runtime.ts` | Review 业务运行时:封装 skills、subagents、hooks、tools |
|
||||
| **KernelTurnPlanner** | `review-kernel-runtime.ts:305-361` | 基于当前 state 决定下一步执行什么任务 |
|
||||
|
||||
### 4.8.2 核心运转流程
|
||||
|
||||
**1. 启动阶段**:
|
||||
```typescript
|
||||
// PR webhook 触发
|
||||
kernelReviewEngine.enqueuePullRequest(payload)
|
||||
→ ensureSession(scopeKey) // 创建或复用 session
|
||||
→ runtime.execute(run, sessionId) // 启动运行时
|
||||
→ AgentKernelRunner.run({ // 启动主循环
|
||||
sessionId,
|
||||
initialState: {...},
|
||||
initialTasks: []
|
||||
})
|
||||
```
|
||||
|
||||
**2. 主循环机制** (`AgentKernelRunner.run`):
|
||||
|
||||
```typescript
|
||||
async run({ sessionId, initialState, initialTasks, continueExisting }) {
|
||||
// 从 checkpoint 恢复状态(支持继续执行)
|
||||
const persisted = loadCheckpoint(sessionId);
|
||||
let state = persisted?.state ?? initialState;
|
||||
const pendingTasks = [...(persisted?.pendingTasks ?? initialTasks)];
|
||||
|
||||
// 主循环:直到有 stopReason
|
||||
while (!stopReason) {
|
||||
// 如果没有待执行任务,让 planner 规划新任务
|
||||
if (pendingTasks.length === 0) {
|
||||
const planned = planner.plan({ session, state, pendingTasks });
|
||||
pendingTasks.push(...planned);
|
||||
}
|
||||
|
||||
// 取出下一个任务
|
||||
const task = pendingTasks.shift();
|
||||
|
||||
// 执行任务
|
||||
const result = await executeTask(task, context);
|
||||
|
||||
// 处理执行结果
|
||||
if (result?.state) state = result.state; // 更新状态
|
||||
if (result?.prepend) pendingTasks.unshift(...result.prepend); // 前置任务
|
||||
if (result?.enqueue) pendingTasks.push(...result.enqueue); // 后置任务
|
||||
if (result?.stopReason) stopReason = result.stopReason; // 停止原因
|
||||
|
||||
// 保存 checkpoint(支持失败恢复)
|
||||
saveCheckpoint(sessionId, { state, pendingTasks, stopReason });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 恢复机制** (`continueExisting`):
|
||||
- 从 SQLite 加载持久化的 checkpoint
|
||||
- 恢复 `state` 和 `pendingTasks`
|
||||
- **显式忽略**旧 checkpoint 的 `stopReason`,允许从 feedback 后继续
|
||||
- 不 replay events,直接继续执行
|
||||
|
||||
### 4.8.3 任务调度与决策
|
||||
|
||||
**Planner 是决策中枢**,根据当前 state 动态决定下一步:
|
||||
|
||||
```typescript
|
||||
private planTasks(context: KernelPlanningContext): KernelTask[] {
|
||||
// 阶段1: 前置条件检查(顺序执行)
|
||||
if (!context.state.workspacePath) {
|
||||
return [{ kind: 'skill', name: 'prepare_workspace' }];
|
||||
}
|
||||
if (!context.state.context) {
|
||||
return [{ kind: 'skill', name: 'build_context' }];
|
||||
}
|
||||
|
||||
// 阶段2: 上下文压缩决策
|
||||
if (shouldCompress(context)) {
|
||||
return [{ kind: 'skill', name: 'compress_context' }];
|
||||
}
|
||||
|
||||
// 阶段3: Triage 决策(生成自主审查提示)
|
||||
if (!context.state.triage) {
|
||||
return [{ kind: 'subagent', name: 'review:triage' }];
|
||||
}
|
||||
|
||||
// 阶段4: 单次完整自主审查
|
||||
if (!context.state.reviewCompleted) {
|
||||
return [{ kind: 'subagent', name: 'review:full_review' }];
|
||||
}
|
||||
|
||||
// 阶段5: 发布与收尾
|
||||
if (!context.state.published) {
|
||||
return [{ kind: 'skill', name: 'publish_review' }];
|
||||
}
|
||||
|
||||
return []; // 完成
|
||||
}
|
||||
```
|
||||
|
||||
**决策依据**:
|
||||
- **当前 State**: `triage`, `reviewCompleted`, `findings`, `published`, `reviewedRefSaved` 等字段
|
||||
- **Tags/Capabilities**: 按标签选择 subagent(`filterByTag('triage')`),非硬编码
|
||||
- **Config 开关**: 审查引擎、工作区、命令白名单等运行配置
|
||||
|
||||
### 4.8.4 Skills 与 Subagents 调用机制
|
||||
|
||||
**Skills - 原子任务**:
|
||||
|
||||
```typescript
|
||||
// 注册 Skills
|
||||
this.skillRegistry.register(createPrepareWorkspaceSkill());
|
||||
this.skillRegistry.register(createBuildContextSkill());
|
||||
|
||||
// Skill 定义
|
||||
{
|
||||
kind: 'skill',
|
||||
name: 'build_context',
|
||||
execute: async (task, context) => {
|
||||
// 执行业务逻辑
|
||||
const reviewContext = await diffExtractor.buildContext(...);
|
||||
|
||||
return {
|
||||
state: { ...context.state, context: reviewContext }, // 更新状态
|
||||
// 可选控制流
|
||||
prepend: [], // 在当前任务前插入新任务
|
||||
enqueue: [], // 在当前任务后追加新任务
|
||||
stopReason: undefined // 或 'completed', 'failed', 'awaiting_human_feedback'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Subagents - 委派执行**:
|
||||
|
||||
```typescript
|
||||
// 调用路径
|
||||
AgentKernelRunner → KernelAgentInvoker.invoke(task, context)
|
||||
→ 创建 invocation record
|
||||
→ 执行 subagent.execute(task, agentContext)
|
||||
→ 完成 invocation,返回结果
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Subagent 执行上下文
|
||||
const agentContext: KernelAgentExecutionContext = {
|
||||
...context,
|
||||
agent, // subagent 定义
|
||||
delegation: { // 委派包
|
||||
goal: agent.whenToUse,
|
||||
parentTaskName: task.name,
|
||||
input: task.input,
|
||||
contextSummary: state.compressedContext?.summary // 压缩摘要回注
|
||||
}
|
||||
};
|
||||
|
||||
// 执行(带 AsyncLocalStorage 隔离)
|
||||
const result = await runWithKernelAgentContext(
|
||||
{ agentId, parentSessionId, agentType: 'subagent', ... },
|
||||
() => agent.execute(task, agentContext)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.8.5 Tools 调用机制
|
||||
|
||||
**调用路径**(在 `review:full_review` 内部):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FullReview as AutonomousReviewAgent
|
||||
participant Loop as Autonomous Loop
|
||||
participant Orchestration as ToolOrchestration
|
||||
participant Permission as Permission Gating
|
||||
participant Hook as PreToolUse Hook
|
||||
participant Tool as Tool.execute()
|
||||
participant PostHook as PostToolUse Hook
|
||||
|
||||
FullReview->>Loop: 决定调用 tool
|
||||
Loop->>Orchestration: partitionToolCalls(tools)
|
||||
Orchestration->>Permission: evaluateToolPermission(tool)
|
||||
Permission-->>Orchestration: allow/ask/deny
|
||||
Orchestration->>Hook: runKernelHooks(PreToolUse)
|
||||
Hook-->>Orchestration: additionalContext/updatedInput
|
||||
Orchestration->>Tool: tool.execute(args)
|
||||
Tool-->>Orchestration: result
|
||||
Orchestration->>PostHook: runKernelHooks(PostToolUse)
|
||||
PostHook-->>Orchestration: -
|
||||
Orchestration-->>Loop: toolResult
|
||||
Loop-->>FullReview: 更新 diagnostics/findings
|
||||
```
|
||||
|
||||
**并发控制**:
|
||||
- **并发安全工具** (`isConcurrencySafe: true`): 并行执行
|
||||
- **非并发安全工具**: 串行执行
|
||||
- **权限拦截**: `PermissionRequest` Hook 可批准/阻断
|
||||
|
||||
**权限边界**:
|
||||
|
||||
| Scope | 默认行为 | 说明 |
|
||||
|-------|----------|------|
|
||||
| `read` | `allow` | 安全操作(读文件、搜索代码) |
|
||||
| `write` | `ask` | 需审批(写文件) |
|
||||
| `command` | `ask` | 需审批(执行命令) |
|
||||
| `git_write` | `ask` | 需审批(Git 操作) |
|
||||
| `network` | `deny` | 禁止网络访问 |
|
||||
| `cross_session` | `deny` | 禁止跨 session 操作 |
|
||||
|
||||
### 4.8.6 代码审查结合流程
|
||||
|
||||
**完整数据流**:
|
||||
|
||||
```
|
||||
Webhook → PR/Commit
|
||||
↓
|
||||
prepare_workspace → 克隆仓库、准备 mirror/workspace
|
||||
↓
|
||||
build_context → 提取 diff、文件内容、构建 ReviewContext
|
||||
↓
|
||||
compress_context (可选) → 大上下文自动压缩,生成 summary
|
||||
↓
|
||||
review:triage → 生成自主审查提示、模式和预算
|
||||
↓
|
||||
review:full_review → 单个自主代理跨文件调查,生成 findings
|
||||
↓
|
||||
publish_review → 发布 summary + line comments
|
||||
↓
|
||||
save_reviewed_ref → 保存审查快照(支持增量审查)
|
||||
```
|
||||
|
||||
**状态流转**:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> prepare_workspace: 启动
|
||||
prepare_workspace --> build_context: 成功
|
||||
build_context --> compress_context: 上下文过大
|
||||
build_context --> triage: 正常
|
||||
compress_context --> triage: 完成
|
||||
triage --> full_review: 提示生成完成
|
||||
full_review --> publish_review: findings 聚合完成
|
||||
publish_review --> save_reviewed_ref: 直接完成
|
||||
save_reviewed_ref --> [*]: completed
|
||||
```
|
||||
|
||||
### 4.8.7 边界划分
|
||||
|
||||
**Skills vs Subagents 边界**:
|
||||
|
||||
| 维度 | Skills | Subagents |
|
||||
|------|--------|-----------|
|
||||
| **粒度** | 原子操作(准备环境、构建上下文、发布) | 复杂推理(规划、完整审查) |
|
||||
| **模型** | 通常不涉及 LLM | 必须调用 LLM(planner/specialist) |
|
||||
| **并发** | 顺序执行 | 通过单个 full review 代理内部自主工具调用实现调查 |
|
||||
| **状态** | 修改 state 字段 | 可修改 state,主要产出 hints/findings/diagnostics |
|
||||
| **失败** | 阻断整个流程 | 可单独重试或降级 |
|
||||
| **示例** | prepare_workspace, publish_review | review:triage, review:full_review |
|
||||
|
||||
**Runtime vs Runner 边界**:
|
||||
|
||||
| 组件 | 职责 | 不做什么 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | 通用调度、checkpoint、task 循环 | 不感知 Review 业务逻辑 |
|
||||
| **ReviewKernelRuntime** | Review 业务封装、skills、subagents、hooks | 不直接调度任务(委托给 runner) |
|
||||
|
||||
**Subagents 间边界**:
|
||||
|
||||
| Subagent | 输入 | 输出 | 边界限制 |
|
||||
|----------|------|------|----------|
|
||||
| **triage** | ReviewContext | review hints + budget | 只生成提示,不审查 |
|
||||
| **full_review** | ReviewTask + context | findings[] + diagnostics | 一次完整自主审查,不预拆域或文件 |
|
||||
|
||||
**Hook 介入边界**:
|
||||
|
||||
```typescript
|
||||
// 在关键生命周期点介入
|
||||
SessionStart // session 启动时
|
||||
SubagentStart // subagent 启动时
|
||||
PreToolUse // 工具调用前(可修改输入、阻断)
|
||||
PermissionRequest // 权限请求时(决定 allow/ask/deny)
|
||||
PostToolUse // 工具调用成功后
|
||||
PostToolUseFailure // 工具调用失败后
|
||||
```
|
||||
|
||||
**Session 隔离边界**:
|
||||
|
||||
- 每个 PR/Commit 对应独立 session
|
||||
- session 间 state 不共享
|
||||
- tool 默认禁止 cross_session 操作
|
||||
- subagent invocation 绑定 parentSessionId
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行时与状态设计
|
||||
|
||||
### 5.1 Session 与 Checkpoint
|
||||
|
||||
每条 PR/commit 审查对应一个 kernel session:
|
||||
|
||||
| 数据 | 用途 |
|
||||
|---|---|
|
||||
| `KernelSessionRecord` | 记录 scopeType、scopeKey、metadata、lastRunId |
|
||||
| `KernelSessionEventRecord` | append-only 事件流,记录 run/task/hook/feedback 生命周期 |
|
||||
| `KernelCheckpoint<TState>` | 持久化 state、pendingTasks、stopReason |
|
||||
| `KernelSubagentInvocationRecord` | 记录每次 subagent 委派调用 |
|
||||
|
||||
恢复语义:
|
||||
|
||||
- `continueExisting=true` 时从 persisted checkpoint 恢复 `state + pendingTasks`;
|
||||
- 显式忽略旧 checkpoint 的 stopReason,允许 feedback 后继续推进;
|
||||
- 当前不 replay session events 重建 state,event 主要用于投影与审计。
|
||||
|
||||
### 5.2 ReviewKernelState
|
||||
|
||||
核心状态包括:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `targetSha` | 当前审查目标 commit |
|
||||
| `mirrorPath/workspacePath` | 本地仓库与工作区路径 |
|
||||
| `context` | `ReviewContext`,包含 diff、changedFiles、fileContents 等 |
|
||||
| `projectPrompt` | 仓库级审查 prompt |
|
||||
| `compressedContext` | 自动压缩摘要及 token 元数据 |
|
||||
| `triage/reviewTask/reviewCompleted` | 自主审查提示、预算与完成状态 |
|
||||
| `findings` | subagents 收集到的问题 |
|
||||
| `reviewDiagnostics` | full review 工具调用、停止原因、解析计数等诊断信息 |
|
||||
| `published/reviewedRefSaved` | 发布与审查快照保存状态位 |
|
||||
|
||||
### 5.3 Subagent Invocation
|
||||
|
||||
每次 subagent 调用会持久化:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `parent_session_id` | 父 session |
|
||||
| `parent_run_id` | 当前 review run |
|
||||
| `parent_task_name` | 触发该调用的 task name |
|
||||
| `subagent_name` | subagent id,例如 `review:triage` |
|
||||
| `agent_id` | 本次调用唯一 agent identity |
|
||||
| `status` | running / completed / failed |
|
||||
| `input_json` | delegation packet |
|
||||
| `result_json` | structured invocation result |
|
||||
|
||||
失败处理:
|
||||
|
||||
- invoker 将 invocation 标记为 `failed`;
|
||||
- runner 写入 `task_failed` event;
|
||||
- checkpoint 保存当前 state 与 `[failedTask, ...pendingTasks]`,stopReason=`failed`;
|
||||
- 调用方可根据 checkpoint 与错误信息决定重试/人工介入。
|
||||
|
||||
### 5.4 上下文压缩与回注
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Planner
|
||||
participant C as ContextCompressionService
|
||||
participant S as Session Checkpoint
|
||||
participant A as Subagent
|
||||
|
||||
P->>C: shouldCompress(context, compressedContext)
|
||||
C-->>P: true when tokenEstimate >= contextWindow * 0.8
|
||||
P->>C: compress(context, projectPrompt)
|
||||
C-->>S: compressedContext(summary, token stats, model, timestamp)
|
||||
P->>A: invoke subagent with contextSummary
|
||||
A-->>A: prompt includes compressed summary
|
||||
```
|
||||
|
||||
压缩触发阈值:
|
||||
|
||||
- 使用 `tokenCounter.getContextWindow(plannerModel)` 获取模型上下文窗口;
|
||||
- 取 80% 作为触发阈值,预留 20% 冗余;
|
||||
- 若无法获取模型配置,兜底使用默认窗口。
|
||||
|
||||
### 5.5 Hooks 与 Permission
|
||||
|
||||
内置 hooks:
|
||||
|
||||
| Hook | Event | 作用 |
|
||||
|---|---|---|
|
||||
| `kernel:session-start-audit` | `SessionStart` | 写入 `hook_session_start` event |
|
||||
| `kernel:subagent-start-audit` | `SubagentStart` | 写入 `hook_subagent_start` event |
|
||||
| `kernel:pre-tool-audit` | `PreToolUse` | 为工具调用追加审计上下文 |
|
||||
| `kernel:permission-request-audit` | `PermissionRequest` | 记录权限请求上下文 |
|
||||
|
||||
工具权限默认策略:
|
||||
|
||||
| Scope | 默认行为 |
|
||||
|---|---|
|
||||
| `read` | allow |
|
||||
| `write` | ask |
|
||||
| `command` | ask |
|
||||
| `git_write` | ask |
|
||||
| `network` | deny |
|
||||
| `cross_session` | deny |
|
||||
|
||||
---
|
||||
|
||||
## 6. API 与管理后台可观测性
|
||||
|
||||
### 6.1 Admin API
|
||||
|
||||
| API | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/review/sessions` | 返回 session 列表与 summary |
|
||||
| `GET /admin/api/review/sessions/:sessionId` | 返回 session、summary、checkpoint、plan、timeline、events、subagentInvocations、runDetails |
|
||||
| `GET /admin/api/review/kernel/tasks` | 返回 skill + subagent task catalog |
|
||||
| `GET /admin/api/review/kernel/subagents` | 返回 subagent catalog |
|
||||
| `GET /admin/api/review/kernel/hooks` | 返回 hook catalog |
|
||||
|
||||
### 6.2 Subagent Catalog 响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "subagent",
|
||||
"name": "review:full_review",
|
||||
"source": "built-in",
|
||||
"description": "执行一次完整自主代码审查",
|
||||
"whenToUse": "当 triage 生成审查提示后执行完整审查",
|
||||
"modelRole": "specialist",
|
||||
"tags": ["review", "specialist", "full-review", "autonomous-review"],
|
||||
"resumable": true
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 管理后台展示建议
|
||||
|
||||
管理后台应采用双层控制面:
|
||||
|
||||
- 上层:Kernel Subagents 目录,展示 built-in/custom/plugin subagents;
|
||||
- 下层:模型角色路由,配置 `planner / specialist` 到 provider/model。
|
||||
|
||||
展示字段建议:
|
||||
|
||||
| 区域 | 字段 |
|
||||
|---|---|
|
||||
| Subagent 目录 | name、source、description、whenToUse、modelRole、tags、resumable |
|
||||
| Session 详情 | summary、plan、timeline、findings、comments、subagentInvocations |
|
||||
| Invocation 详情 | agentId、status、startedAt、finishedAt、summary、artifacts |
|
||||
|
||||
---
|
||||
|
||||
## 7. 非功能性设计
|
||||
|
||||
### 7.1 安全设计
|
||||
|
||||
- 工具调用统一走 permission gating,避免 subagent 绕过权限策略;
|
||||
- 高风险工具默认 ask/deny,不允许直接执行网络、跨 session 或写操作;
|
||||
- hooks 可作为后续审批、审计、通知与策略扩展点;
|
||||
- LLM prompt 不作为安全边界,所有外部副作用必须由 tool/skill/adapters 承载。
|
||||
|
||||
### 7.2 高可用与恢复
|
||||
|
||||
- 每个 task 完成后保存 checkpoint,降低失败后的重复工作;
|
||||
- subagent invocation 失败会记录 failed 状态,便于定位失败代理;
|
||||
- feedback 后通过 `continueExisting` 从 checkpoint 继续;
|
||||
- publish 与 save reviewed ref 分离,避免评论发布与 ref 保存互相污染;
|
||||
- cleanup workspace 放在 runtime finally 中执行,降低资源泄漏风险。
|
||||
|
||||
### 7.3 可观测性
|
||||
|
||||
- session event 记录 run/task/hook/feedback 生命周期;
|
||||
- subagent invocation 记录 parent-child 委派关系;
|
||||
- admin projection 汇总 plan/timeline/currentStep/findingCount/pendingTaskCount;
|
||||
- compression 记录 sourceTokenEstimate、summaryTokenEstimate、triggerThreshold、model。
|
||||
|
||||
### 7.4 性能与容量
|
||||
|
||||
- 大 diff 先经 diff extractor/token budget 裁剪,再由 compression service 做会话级摘要;
|
||||
- `review:full_review` 在单个自主循环内使用工具逐步调查,避免运行时预拆 domain 或文件;
|
||||
- tool orchestration 可并发执行 read-only 工具,非并发安全工具串行;
|
||||
- session/event/checkpoint 使用 SQLite,适合当前单体部署;未来高并发可迁移到外部数据库。
|
||||
|
||||
### 7.5 可维护性与扩展性
|
||||
|
||||
- 新增内置 Agent 应只新增 `KernelSubagentDefinition` 并打 tags;
|
||||
- 新增流程副作用应优先实现 skill/adapters;
|
||||
- 新增横切逻辑应优先实现 hook;
|
||||
- 新增工具必须声明 permissionScope 和 isConcurrencySafe。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与上线验证
|
||||
|
||||
### 8.1 自动化测试分层
|
||||
|
||||
| 层级 | 测试文件 | 覆盖点 |
|
||||
|---|---|---|
|
||||
| Unit | `src/review/kernel/__tests__/session-read-model.test.ts` | session summary/plan/timeline 投影 |
|
||||
| Unit | `src/review/tools/__tests__/tool-permissions.test.ts` | permission scope 默认策略 |
|
||||
| Contract | `src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts` | hook 聚合、approve/block、updatedInput |
|
||||
| Integration | `src/controllers/__tests__/admin-review-sessions.test.ts` | admin session 与 catalog API |
|
||||
| Integration | `src/controllers/__tests__/feedback-kernel-session.test.ts` | feedback approve/reject/rollback/continue |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-happy-path.test.ts` | 完整 runtime happy path |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-feedback-resume.test.ts` | awaiting feedback 后恢复 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-replay-invariants.test.ts` | checkpoint/resume/replay 不变量 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-concurrency-idempotency.test.ts` | 并发上限与幂等 |
|
||||
| Canary | `src/review/kernel/__tests__/compression-resumability.test.ts` | 压缩恢复与生产关键 canary |
|
||||
|
||||
### 8.2 上线前门禁
|
||||
|
||||
必须通过:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run build
|
||||
bun test src/review/kernel/__tests__ src/review/tools/__tests__ src/controllers/__tests__ src/agent-kernel/hooks/__tests__
|
||||
bun test
|
||||
```
|
||||
|
||||
关键验收信号:
|
||||
|
||||
- runtime happy path 完成,stopReason=`completed`;
|
||||
- feedback resume 从 `awaiting_human_feedback` 恢复到 completed;
|
||||
- compression resume 保留 targetSha、pending boundary、invocation boundary、summary;
|
||||
- permission deny 不会绕过工具治理;
|
||||
- duplicate enqueue/continue/feedback 不产生重复有效工作;
|
||||
- admin session detail 能看到 plan/timeline/subagentInvocations。
|
||||
|
||||
### 8.3 灰度与回滚
|
||||
|
||||
- 配置默认:`REVIEW_ENGINE=kernel`;
|
||||
- 若需要回滚,可临时切到 `codex` 引擎,但旧固定 agent 编排不再作为主路径;
|
||||
- 灰度期间重点观察 session stopReason 分布、task_failed 事件、subagent failed invocations、feedback resume 成功率。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险、待确认与后续演进
|
||||
|
||||
### 9.1 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
|---|---|---|
|
||||
| Built-in definitions 仍在代码中 | 扩展仍需发版 | 下一阶段引入 plugin/custom subagent loader |
|
||||
| SQLite 单文件并发能力有限 | 高并发 session 下写入竞争 | 当前单体可接受;未来迁移外部 DB 或队列化写入 |
|
||||
| Compression summary 可能遗漏细节 | 后续 subagent 判断偏差 | 保留 recent context + summary;测试锁定关键事实不丢 |
|
||||
| Hook 阻断策略过强或过弱 | 工具误阻断或越权 | permission matrix 测试 + 审计 event + 管理后台策略展示 |
|
||||
|
||||
### 9.2 后续演进计划
|
||||
|
||||
1. **Plugin-based Subagent Loading**:支持从目录或配置加载 custom/plugin subagents。
|
||||
2. **Child Session Tree**:为长任务或后台 subagent 引入 child session/resume tree。
|
||||
3. **Attachment Reinjection**:压缩后恢复文件附件、计划附件和技能附件。
|
||||
4. **更细粒度权限模型**:支持仓库级、工具级、用户级策略配置。
|
||||
5. **Subagent 版本治理**:为 built-in/custom/plugin subagents 增加 version、enabled、rollout 字段。
|
||||
|
||||
### 9.3 评审清单
|
||||
|
||||
- [ ] 内置 Agent 是否都通过 registry/invoker 调用,而不是 runtime 硬编码实例?
|
||||
- [ ] planner 是否按 tag/capability 选择 subagent?
|
||||
- [ ] 每次 subagent 调用是否有 invocation record?
|
||||
- [ ] feedback 后 continue 是否从 checkpoint 恢复?
|
||||
- [ ] 压缩 summary 是否持久化并回注 triage/full_review?
|
||||
- [ ] 工具执行是否经过 permission/hook/orchestration?
|
||||
- [ ] 管理后台是否能展示 catalog、timeline、invocations?
|
||||
- [ ] 生产测试门禁是否覆盖 happy path、失败恢复、幂等和 canary?
|
||||
|
||||
---
|
||||
|
||||
## 版本记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v0.1 | 2026-04-28 | 初版:记录 Kernel 内置 Agent 架构、运行链路、可观测性与测试门禁 |
|
||||
617
docs/design/notification-service-refactoring.md
Normal file
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
|
||||
**状态**: 已实施(持续验证中)
|
||||
836
docs/design/pluggable-llm-providers.md
Normal file
836
docs/design/pluggable-llm-providers.md
Normal file
@@ -0,0 +1,836 @@
|
||||
# 技术设计文档:可插拔 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/embedding)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner', -- Agent 审查 planner
|
||||
'specialist', -- Agent 审查 specialist
|
||||
'judge', -- Agent 审查 judge
|
||||
'embedding' -- 向量嵌入(Qdrant)
|
||||
)),
|
||||
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' | 'embedding';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 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/embedding)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 用指定 provider 直接调用(连通性测试用)
|
||||
*/
|
||||
async chatDirect(
|
||||
providerId: string,
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 获取指定 provider 的 embedding 接口
|
||||
*/
|
||||
async embedForRole(
|
||||
role: 'embedding',
|
||||
texts: string[]
|
||||
): Promise<number[][]>;
|
||||
|
||||
/** 配置变更时清除单个 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 ] │
|
||||
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
├── ⚙️ 通用设置(现有 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/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
|
||||
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
|
||||
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |
|
||||
| 8 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 9 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 10 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 11 | `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 映射表全覆盖测试 |
|
||||
| **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection |
|
||||
| **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
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
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
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` 查看服务状态。
|
||||
46
docs/review-engines.md
Normal file
46
docs/review-engines.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Review Engines
|
||||
|
||||
## Overview
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native staged review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
|
||||
## Agent engine
|
||||
|
||||
Agent engine classifies changes and dispatches specialist tasks.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: low-risk changes may bypass specialist review
|
||||
- `light`: minimal specialist checks for low-risk code changes
|
||||
- `full`: full specialist review for risky or larger changes
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used by triage to choose 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
|
||||
46
docs/review-engines.zh-CN.md
Normal file
46
docs/review-engines.zh-CN.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 审查引擎
|
||||
|
||||
## 概览
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:原生任务化分级审查
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎会先做变更分流,再按领域派发 specialist 任务。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可跳过 specialist
|
||||
- `light`:对低风险代码执行最小化专项检查
|
||||
- `full`:对高风险或大规模改动执行完整审查
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于 triage 阶段决策模式与 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
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
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,8 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
COPY frontend/dist ./public
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
169
e2e/__tests__/e2e-review.test.ts
Normal file
169
e2e/__tests__/e2e-review.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
E2ETestHarness,
|
||||
type Finding,
|
||||
type Scenario,
|
||||
type SessionDetail,
|
||||
} from './e2e-test-harness';
|
||||
|
||||
function assertFindingsMatchScenario(findings: Finding[], scenario: Scenario): void {
|
||||
expect(findings.length).toBeGreaterThanOrEqual(scenario.minFindings);
|
||||
|
||||
if (scenario.maxFindings !== undefined) {
|
||||
expect(findings.length).toBeLessThanOrEqual(scenario.maxFindings);
|
||||
}
|
||||
|
||||
const highSeverityCount = findings.filter((finding) => finding.severity === 'high').length;
|
||||
expect(highSeverityCount).toBeGreaterThanOrEqual(scenario.minHighSeverity);
|
||||
|
||||
const fingerprints = findings
|
||||
.map((finding) => finding.fingerprint)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||
}
|
||||
|
||||
function expectPipelineStepsCompleted(detail: SessionDetail): void {
|
||||
const statusesByKey = new Map(detail.plan.map((step) => [step.key, step.status]));
|
||||
expect(statusesByKey.get('prepare_workspace')).toBe('completed');
|
||||
expect(statusesByKey.get('build_context')).toBe('completed');
|
||||
expect(statusesByKey.get('review:triage')).toBe('completed');
|
||||
expect(statusesByKey.get('review:full_review')).toBe('completed');
|
||||
expect(statusesByKey.get('aggregate_findings')).toBe('completed');
|
||||
expect(statusesByKey.get('publish_review')).toBe('completed');
|
||||
expect(statusesByKey.get('save_reviewed_ref')).toBe('completed');
|
||||
}
|
||||
|
||||
function expectAutonomousFullReviewPipeline(detail: SessionDetail): void {
|
||||
const fullReviewInvocations = detail.subagentInvocations.filter(
|
||||
(invocation) => invocation.subagentName === 'review:full_review'
|
||||
);
|
||||
expect(fullReviewInvocations).toHaveLength(1);
|
||||
expect(fullReviewInvocations[0].status).toBe('completed');
|
||||
expect(detail.checkpoint?.state?.reviewCompleted).toBe(true);
|
||||
expect(detail.checkpoint?.state?.published).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewedRefSaved).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.toolCallNames).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
'read_file',
|
||||
]);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.stopReason).toBe('modelFinalized');
|
||||
|
||||
const findings = detail.checkpoint?.state?.findings ?? [];
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0].detail).toContain('auth/user model');
|
||||
expect(findings[0].evidence).toContain('src/auth.ts');
|
||||
|
||||
const publishedComments = detail.runDetails?.comments?.filter(
|
||||
(comment) => comment.status === 'published'
|
||||
);
|
||||
expect(publishedComments?.length).toBeGreaterThan(0);
|
||||
expect(publishedComments?.some((comment) => !comment.path)).toBe(true);
|
||||
expect(publishedComments?.some((comment) => comment.path === 'src/user-handler.ts')).toBe(true);
|
||||
}
|
||||
|
||||
describe('E2E Review Flow', () => {
|
||||
const harness = new E2ETestHarness();
|
||||
|
||||
beforeAll(async () => {
|
||||
await harness.start();
|
||||
await harness.seedGitea();
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.stop();
|
||||
});
|
||||
|
||||
test('核心链路验证: webhook → clone → triage → full_review → aggregate → publish → save ref → Gitea has comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('simple-bug-pr');
|
||||
|
||||
const webhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(webhookResponse.status).toBe('accepted');
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.checkpoint?.state?.published).toBe(true);
|
||||
expectAutonomousFullReviewPipeline(result.detail);
|
||||
|
||||
const comments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(comments.length).toBeGreaterThan(0);
|
||||
}, 150_000);
|
||||
|
||||
test('状态正确性: session status transitions and checkpoint consistency', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('security-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const snapshot = await harness.waitForSessionSnapshot(owner, repo, prNumber, 30);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(
|
||||
snapshot.detail.summary.status
|
||||
);
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(result.observedStates[0]);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expect(result.detail.checkpoint?.stopReason).toBe('completed');
|
||||
expect(result.detail.checkpoint?.pendingTasks ?? []).toHaveLength(0);
|
||||
expect(result.detail.summary.findingCount).toBe(harness.extractFindings(result.detail).length);
|
||||
}, 150_000);
|
||||
|
||||
test('Findings 质量: fixtures trigger expected triage modes, autonomous full review, and finding counts', async () => {
|
||||
const fixtureNames = ['simple-bug-pr', 'minimal-change-pr'];
|
||||
|
||||
for (const fixtureName of fixtureNames) {
|
||||
const { owner, repo, prNumber, scenario } = await harness.seedPR(fixtureName);
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
|
||||
const triageMode = harness.extractTriageMode(result.detail);
|
||||
if (triageMode !== undefined) {
|
||||
expect(triageMode).toBe(scenario.expectedTriageMode);
|
||||
}
|
||||
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.subagentInvocations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ subagentName: 'review:full_review', status: 'completed' }),
|
||||
])
|
||||
);
|
||||
|
||||
assertFindingsMatchScenario(harness.extractFindings(result.detail), scenario);
|
||||
}
|
||||
}, 360_000);
|
||||
|
||||
test('幂等性: duplicate webhook does not create duplicate comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('duplicate-webhook-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const firstResult = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(firstResult.sessionState).toBe('completed');
|
||||
const firstComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(firstComments.length).toBeGreaterThan(0);
|
||||
|
||||
const duplicateWebhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(['accepted', 'deduplicated']).toContain(duplicateWebhookResponse.status);
|
||||
const secondResult = await harness.waitForReview(owner, repo, prNumber, 60);
|
||||
expect(secondResult.sessionId).toBe(firstResult.sessionId);
|
||||
const secondComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
|
||||
expect(secondComments.length).toBe(firstComments.length);
|
||||
expect(new Set(secondComments.map((comment) => comment.body)).size).toBe(
|
||||
new Set(firstComments.map((comment) => comment.body)).size
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
test('错误恢复: clone failure marks session failed, not stuck', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('clean-refactor-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber, {
|
||||
repositoryPatch: {
|
||||
clone_url: `http://invalid-host-99999.local/${owner}/${repo}-missing.git`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['completed', 'failed']).toContain(result.sessionState);
|
||||
}, 150_000);
|
||||
});
|
||||
748
e2e/__tests__/e2e-test-harness.ts
Normal file
748
e2e/__tests__/e2e-test-harness.ts
Normal file
@@ -0,0 +1,748 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
const WEBHOOK_SECRET = 'e2e-test-webhook-secret';
|
||||
const TERMINAL_STATES = new Set(['completed', 'failed', 'ignored', 'cancelled', 'error']);
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface GiteaUser {
|
||||
login: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
interface GiteaRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
html_url: string;
|
||||
ssh_url?: string;
|
||||
owner: GiteaUser;
|
||||
}
|
||||
|
||||
interface GiteaPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string;
|
||||
head: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
base: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
requested_reviewers?: GiteaUser[];
|
||||
user?: GiteaUser;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
expectedTriageMode: string;
|
||||
expectedDomains: string[];
|
||||
minFindings: number;
|
||||
maxFindings?: number;
|
||||
minHighSeverity: number;
|
||||
testIdempotency?: boolean;
|
||||
}
|
||||
|
||||
interface AdminLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
sessionId: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
status: string;
|
||||
findingCount: number;
|
||||
}
|
||||
|
||||
interface SessionListEntry {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
}
|
||||
|
||||
interface SessionListResponse {
|
||||
data: SessionListEntry[];
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
severity?: string;
|
||||
confidence?: number;
|
||||
path?: string;
|
||||
line?: number;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
evidence?: string;
|
||||
category?: string;
|
||||
domain?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
checkpoint: {
|
||||
stopReason?: string;
|
||||
pendingTasks?: Array<{ name: string }>;
|
||||
state?: {
|
||||
targetSha?: string;
|
||||
triage?: {
|
||||
mode?: string;
|
||||
domains?: string[];
|
||||
};
|
||||
triageMode?: string;
|
||||
findings?: Finding[];
|
||||
published?: boolean;
|
||||
reviewedRefSaved?: boolean;
|
||||
reviewCompleted?: boolean;
|
||||
reviewedRef?: string;
|
||||
reviewDiagnostics?: {
|
||||
toolCallNames?: string[];
|
||||
toolCallCount?: number;
|
||||
parsedFindingCount?: number;
|
||||
stopReason?: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
plan: Array<{ key: string; status: string; label: string }>;
|
||||
events: Array<{ eventType: string; payload: Record<string, JsonValue> }>;
|
||||
runDetails: {
|
||||
findings?: Finding[];
|
||||
comments?: Array<{
|
||||
status?: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body?: string;
|
||||
fingerprint?: string;
|
||||
}>;
|
||||
} | null;
|
||||
subagentInvocations: Array<{
|
||||
subagentName: string;
|
||||
status: string;
|
||||
result?: Record<string, JsonValue>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GiteaTokenResponse {
|
||||
sha1?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface CommentLike {
|
||||
id: number;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface SeedResult {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
scenario: Scenario;
|
||||
}
|
||||
|
||||
interface ReviewWaitResult {
|
||||
completed: boolean;
|
||||
sessionState: string;
|
||||
sessionId: string;
|
||||
detail: SessionDetail;
|
||||
observedStates: string[];
|
||||
}
|
||||
|
||||
interface TriggerWebhookOptions {
|
||||
repositoryPatch?: Partial<GiteaRepo>;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export class E2ETestHarness {
|
||||
readonly giteaUrl = (process.env.E2E_GITEA_URL ?? 'http://localhost:3333').replace(/\/$/, '');
|
||||
readonly adminUser = process.env.E2E_GITEA_ADMIN_USER ?? 'e2e-admin';
|
||||
readonly adminPass = process.env.E2E_GITEA_ADMIN_PASS ?? 'e2ePassword123!';
|
||||
|
||||
private assistantProcess?: Bun.Subprocess<'pipe', 'pipe', 'pipe'>;
|
||||
private assistantPort = 43100 + Math.floor(Math.random() * 1000);
|
||||
private tempDir = mkdtempSync(path.join(tmpdir(), 'e2e-assistant-'));
|
||||
private databasePath = path.join(this.tempDir, 'assistant.db');
|
||||
private reviewWorkDir = path.join(this.tempDir, 'review-workdir');
|
||||
private adminJwt?: string;
|
||||
private giteaToken?: string;
|
||||
private repoCounter = 0;
|
||||
|
||||
get assistantUrl(): string {
|
||||
return `http://127.0.0.1:${this.assistantPort}`;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.startAssistant();
|
||||
this.adminJwt = await this.getAdminJWT();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopAssistant();
|
||||
}
|
||||
|
||||
async startAssistant(): Promise<void> {
|
||||
if (this.assistantProcess) return;
|
||||
|
||||
this.assistantProcess = Bun.spawn(['bun', 'run', 'src/index.ts'], {
|
||||
cwd: path.resolve(import.meta.dir, '../..'),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_MOCK_LLM: '1',
|
||||
ENCRYPTION_KEY,
|
||||
DATABASE_PATH: this.databasePath,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
PORT: String(this.assistantPort),
|
||||
LOG_LEVEL: process.env.LOG_LEVEL ?? 'error',
|
||||
},
|
||||
});
|
||||
|
||||
this.drainProcessOutput(this.assistantProcess.stdout, 'assistant stdout');
|
||||
this.drainProcessOutput(this.assistantProcess.stderr, 'assistant stderr');
|
||||
await this.waitForAssistantHealth();
|
||||
}
|
||||
|
||||
stopAssistant(): void {
|
||||
if (this.assistantProcess) {
|
||||
this.assistantProcess.kill();
|
||||
this.assistantProcess = undefined;
|
||||
}
|
||||
|
||||
if (existsSync(this.tempDir)) {
|
||||
rmSync(this.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async seedGitea(): Promise<void> {
|
||||
await this.waitForGitea();
|
||||
await this.ensureAdminUser();
|
||||
this.giteaToken = await this.createToken();
|
||||
await this.configureAssistant();
|
||||
}
|
||||
|
||||
async seedPR(scenarioName: string): Promise<SeedResult> {
|
||||
if (!this.giteaToken) {
|
||||
await this.seedGitea();
|
||||
}
|
||||
|
||||
const scenario = await this.readScenario(scenarioName);
|
||||
const owner = this.adminUser;
|
||||
const repo = `e2e-${scenarioName.replace(/[^a-z0-9-]/gi, '-')}-${Date.now()}-${this.repoCounter++}`;
|
||||
const baseBranch = 'main';
|
||||
const featureBranch = `feature/${scenarioName}-${this.repoCounter}`;
|
||||
|
||||
await this.createRepo(repo);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'base'),
|
||||
`test: seed ${scenario.name} base`
|
||||
);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
featureBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'branch'),
|
||||
`feat: ${scenario.description}`
|
||||
);
|
||||
const pr = await this.createPullRequest(
|
||||
owner,
|
||||
repo,
|
||||
scenario.description,
|
||||
featureBranch,
|
||||
baseBranch
|
||||
);
|
||||
await this.createWebhook(owner, repo);
|
||||
|
||||
return { owner, repo, prNumber: pr.number, scenario };
|
||||
}
|
||||
|
||||
async triggerWebhook(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
options: TriggerWebhookOptions = {}
|
||||
): Promise<{ status: string; runId?: string }> {
|
||||
const repository = await this.giteaFetch<GiteaRepo>(`/repos/${owner}/${repo}`);
|
||||
const pullRequest = await this.giteaFetch<GiteaPullRequest>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}`
|
||||
);
|
||||
const normalizedRepository = this.normalizeRepoUrls({
|
||||
...repository,
|
||||
...options.repositoryPatch,
|
||||
owner: repository.owner,
|
||||
});
|
||||
const payload = {
|
||||
action: options.action ?? 'opened',
|
||||
number: prNumber,
|
||||
pull_request: {
|
||||
...pullRequest,
|
||||
head: {
|
||||
...pullRequest.head,
|
||||
repo: pullRequest.head.repo ? this.normalizeRepoUrls(pullRequest.head.repo) : undefined,
|
||||
},
|
||||
base: {
|
||||
...pullRequest.base,
|
||||
repo: pullRequest.base.repo ? this.normalizeRepoUrls(pullRequest.base.repo) : undefined,
|
||||
},
|
||||
requested_reviewers: pullRequest.requested_reviewers ?? [],
|
||||
},
|
||||
repository: normalizedRepository,
|
||||
sender: repository.owner,
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
|
||||
return this.fetchJson<{ status: string; runId?: string }>(
|
||||
`${this.assistantUrl}/webhook/gitea`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gitea-Event': 'pull_request',
|
||||
'X-Gitea-Signature': signature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async waitForReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 120
|
||||
): Promise<ReviewWaitResult> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
const observedStates: string[] = [];
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
const status = entry.summary.status;
|
||||
if (observedStates.at(-1) !== status) observedStates.push(status);
|
||||
const detail = await this.getSessionDetail(entry.summary.sessionId);
|
||||
const detailStatus = detail.summary.status;
|
||||
if (observedStates.at(-1) !== detailStatus) observedStates.push(detailStatus);
|
||||
|
||||
if (TERMINAL_STATES.has(detailStatus)) {
|
||||
return {
|
||||
completed: detailStatus === 'completed',
|
||||
sessionState: detailStatus,
|
||||
sessionId: entry.summary.sessionId,
|
||||
detail,
|
||||
observedStates,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for review ${owner}/${repo}#${prNumber}; observed states: ${observedStates.join(' -> ') || 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
async waitForSessionSnapshot(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 30
|
||||
): Promise<{ entry: SessionListEntry; detail: SessionDetail }> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
return { entry, detail: await this.getSessionDetail(entry.summary.sessionId) };
|
||||
}
|
||||
await this.sleep(500);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for session snapshot ${owner}/${repo}#${prNumber}`);
|
||||
}
|
||||
|
||||
async getAdminJWT(): Promise<string> {
|
||||
const response = await this.fetchJson<AdminLoginResponse>(
|
||||
`${this.assistantUrl}/admin/api/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'password' }),
|
||||
}
|
||||
);
|
||||
return response.token;
|
||||
}
|
||||
|
||||
async getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||
return this.adminFetch<SessionDetail>(
|
||||
`/admin/api/review/sessions/${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
}
|
||||
|
||||
async getGiteaComments(owner: string, repo: string, prNumber: number): Promise<CommentLike[]> {
|
||||
const issueComments = await this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/issues/${prNumber}/comments`
|
||||
);
|
||||
|
||||
const reviews = await this.giteaFetch<{ id: number }[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
|
||||
);
|
||||
const reviewCommentLists = await Promise.all(
|
||||
reviews.map((r) =>
|
||||
this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews/${r.id}/comments`
|
||||
).catch(() => [] as CommentLike[])
|
||||
)
|
||||
);
|
||||
const reviewComments = reviewCommentLists.flat();
|
||||
|
||||
return [...issueComments, ...reviewComments];
|
||||
}
|
||||
|
||||
extractFindings(detail: SessionDetail): Finding[] {
|
||||
return detail.checkpoint?.state?.findings ?? detail.runDetails?.findings ?? [];
|
||||
}
|
||||
|
||||
extractTriageMode(detail: SessionDetail): string | undefined {
|
||||
return detail.checkpoint?.state?.triage?.mode ?? detail.checkpoint?.state?.triageMode;
|
||||
}
|
||||
|
||||
extractDomains(detail: SessionDetail): string[] {
|
||||
const triageDomains = detail.checkpoint?.state?.triage?.domains;
|
||||
return triageDomains ?? [];
|
||||
}
|
||||
|
||||
private async configureAssistant(): Promise<void> {
|
||||
await this.putConfig({
|
||||
GITEA_API_URL: `${this.giteaUrl}/api/v1`,
|
||||
GITEA_ACCESS_TOKEN: this.requireToken(),
|
||||
GITEA_ADMIN_TOKEN: this.requireToken(),
|
||||
WEBHOOK_SECRET,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
REVIEW_WORKDIR: this.reviewWorkDir,
|
||||
REVIEW_COMMAND_TIMEOUT_MS: '30000',
|
||||
REVIEW_ALLOWED_COMMANDS: 'git,rg,cat,sed,wc',
|
||||
});
|
||||
}
|
||||
|
||||
private async putConfig(values: Record<string, string>): Promise<void> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
const response = await fetch(`${this.assistantUrl}/admin/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to configure assistant: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async findSession(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number
|
||||
): Promise<SessionListEntry | undefined> {
|
||||
const payload = await this.adminFetch<SessionListResponse>(
|
||||
'/admin/api/review/sessions?limit=100'
|
||||
);
|
||||
return payload.data.find((entry) => {
|
||||
const metadata = entry.session.metadata ?? {};
|
||||
const metadataOwner = typeof metadata.owner === 'string' ? metadata.owner : undefined;
|
||||
const metadataRepo = typeof metadata.repo === 'string' ? metadata.repo : undefined;
|
||||
const metadataPr =
|
||||
typeof metadata.prNumber === 'number' ? metadata.prNumber : Number(metadata.prNumber);
|
||||
return (
|
||||
(entry.summary.owner ?? metadataOwner) === owner &&
|
||||
(entry.summary.repo ?? metadataRepo) === repo &&
|
||||
(entry.summary.prNumber ?? metadataPr) === prNumber
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async adminFetch<T>(apiPath: string): Promise<T> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
return this.fetchJson<T>(`${this.assistantUrl}${apiPath}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForAssistantHealth(): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.assistantUrl}/api/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
}
|
||||
throw new Error(`Assistant did not become healthy at ${this.assistantUrl}`);
|
||||
}
|
||||
|
||||
private async waitForGitea(): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/version`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
await this.sleep(2000);
|
||||
}
|
||||
throw new Error(`Gitea did not become available at ${this.giteaUrl}`);
|
||||
}
|
||||
|
||||
private async ensureAdminUser(): Promise<void> {
|
||||
const loginCheck = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (loginCheck.ok) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
username: this.adminUser,
|
||||
password: this.adminPass,
|
||||
email: `${this.adminUser}@e2e-test.local`,
|
||||
must_change_password: false,
|
||||
login_name: this.adminUser,
|
||||
admin_permission: true,
|
||||
});
|
||||
|
||||
for (const [user, pass] of [
|
||||
[this.adminUser, this.adminPass],
|
||||
['root', 'root'],
|
||||
] as const) {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${user}:${pass}`)}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 422 || response.status === 409) return;
|
||||
}
|
||||
|
||||
const retryLogin = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (!retryLogin.ok) {
|
||||
throw new Error(
|
||||
`Unable to create or authenticate Gitea admin user: ${retryLogin.status} ${await retryLogin.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createToken(): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${this.giteaUrl}/api/v1/users/${encodeURIComponent(this.adminUser)}/tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}`,
|
||||
},
|
||||
body: JSON.stringify({ name: `e2e-token-${Date.now()}`, scopes: ['all'] }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Gitea token: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GiteaTokenResponse;
|
||||
const token = payload.sha1 ?? payload.token;
|
||||
if (!token) throw new Error('Gitea token response did not include sha1/token');
|
||||
return token;
|
||||
}
|
||||
|
||||
private async createRepo(name: string): Promise<GiteaRepo> {
|
||||
return this.giteaFetch<GiteaRepo>('/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, auto_init: true, default_branch: 'main' }),
|
||||
});
|
||||
}
|
||||
|
||||
private async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
description: string,
|
||||
head: string,
|
||||
base: string
|
||||
): Promise<GiteaPullRequest> {
|
||||
return this.giteaFetch<GiteaPullRequest>(`/repos/${owner}/${repo}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `E2E: ${description}`,
|
||||
body: `E2E test PR: ${description}`,
|
||||
head,
|
||||
base,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async createWebhook(owner: string, repo: string): Promise<void> {
|
||||
await this.giteaFetch<JsonValue>(`/repos/${owner}/${repo}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['pull_request'],
|
||||
config: {
|
||||
url: `${this.assistantUrl}/webhook/gitea`,
|
||||
content_type: 'json',
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async giteaFetch<T>(apiPath: string, init: RequestInit = {}): Promise<T> {
|
||||
return this.fetchJson<T>(`${this.giteaUrl}/api/v1${apiPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${this.requireToken()}`,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private async readScenario(scenarioName: string): Promise<Scenario> {
|
||||
const scenarioPath = path.join(this.fixturesDir(), scenarioName, 'scenario.json');
|
||||
return JSON.parse(await readFile(scenarioPath, 'utf-8')) as Scenario;
|
||||
}
|
||||
|
||||
private async readFixtureFiles(
|
||||
scenarioName: string,
|
||||
fixturePart: 'base' | 'branch'
|
||||
): Promise<Record<string, string>> {
|
||||
const dir = path.join(this.fixturesDir(), scenarioName, fixturePart);
|
||||
const files: Record<string, string> = {};
|
||||
const glob = new Bun.Glob('**/*');
|
||||
|
||||
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
|
||||
files[file] = await readFile(path.join(dir, file), 'utf-8');
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async pushBranchWithFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
files: Record<string, string>,
|
||||
commitMessage: string
|
||||
): Promise<void> {
|
||||
const tmpDir = mkdtempSync(
|
||||
path.join(tmpdir(), `e2e-push-${branchName.replace(/[^a-z0-9-]/gi, '-')}-`)
|
||||
);
|
||||
const cloneUrl = `${this.giteaUrl.replace('http://', `http://${this.adminUser}:${this.adminPass}@`)}/${owner}/${repo}.git`;
|
||||
|
||||
try {
|
||||
await this.exec(['git', 'clone', cloneUrl, tmpDir]);
|
||||
await this.exec(['git', 'checkout', '-B', branchName], tmpDir);
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const destination = path.join(tmpDir, filePath);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
await Bun.write(destination, content);
|
||||
}
|
||||
|
||||
await this.exec(['git', 'config', 'user.email', 'e2e@test.local'], tmpDir);
|
||||
await this.exec(['git', 'config', 'user.name', 'E2E Bot'], tmpDir);
|
||||
await this.exec(['git', 'add', '-A'], tmpDir);
|
||||
await this.exec(['git', 'commit', '-m', commitMessage, '--allow-empty'], tmpDir);
|
||||
await this.exec(['git', 'push', 'origin', branchName, '--force'], tmpDir);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async exec(args: string[], cwd?: string): Promise<void> {
|
||||
const proc = Bun.spawn(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Command failed (${args.join(' ')}):\n${stdout}\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
private fixturesDir(): string {
|
||||
return path.resolve(import.meta.dir, '../fixtures');
|
||||
}
|
||||
|
||||
private normalizeRepoUrls(repo: GiteaRepo): GiteaRepo {
|
||||
return {
|
||||
...repo,
|
||||
clone_url: this.normalizeGiteaUrl(repo.clone_url),
|
||||
html_url: this.normalizeGiteaUrl(repo.html_url),
|
||||
ssh_url: repo.ssh_url ? this.normalizeGiteaUrl(repo.ssh_url) : repo.ssh_url,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGiteaUrl(value: string): string {
|
||||
return value.replace('http://gitea:3000', this.giteaUrl);
|
||||
}
|
||||
|
||||
private requireToken(): string {
|
||||
if (!this.giteaToken) throw new Error('Gitea token is not initialized');
|
||||
return this.giteaToken;
|
||||
}
|
||||
|
||||
private drainProcessOutput(stream: ReadableStream<Uint8Array>, label: string): void {
|
||||
void new Response(stream).text().then((output) => {
|
||||
if (output.trim().length > 0 && process.env.E2E_DEBUG === '1') {
|
||||
console.log(`[${label}] ${output}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export type { Finding, ReviewWaitResult, Scenario, SeedResult, SessionDetail };
|
||||
21
e2e/fixtures/clean-refactor-pr/base/src/service.ts
Normal file
21
e2e/fixtures/clean-refactor-pr/base/src/service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
const rounded = Math.round(order.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Order ${order.id}: $${formatted}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
const rounded = Math.round(invoice.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Invoice ${invoice.id}: $${formatted}`;
|
||||
}
|
||||
22
e2e/fixtures/clean-refactor-pr/branch/src/service.ts
Normal file
22
e2e/fixtures/clean-refactor-pr/branch/src/service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function formatCurrency(total: number): string {
|
||||
const rounded = Math.round(total * 100) / 100;
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
return `Order ${order.id}: $${formatCurrency(order.total)}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
return `Invoice ${invoice.id}: $${formatCurrency(invoice.total)}`;
|
||||
}
|
||||
9
e2e/fixtures/clean-refactor-pr/scenario.json
Normal file
9
e2e/fixtures/clean-refactor-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "clean-refactor-pr",
|
||||
"description": "正确的重构",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
7
e2e/fixtures/docs-only-pr/base/src/app.ts
Normal file
7
e2e/fixtures/docs-only-pr/base/src/app.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function startApp(): string {
|
||||
return 'sunny-cactus app started';
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
console.log(startApp());
|
||||
}
|
||||
7
e2e/fixtures/docs-only-pr/branch/README.md
Normal file
7
e2e/fixtures/docs-only-pr/branch/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Sunny Cactus Demo
|
||||
|
||||
This fixture updates documentation only. It explains how to start the sample app and does not change runtime behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the application entrypoint and verify that it prints a startup message.
|
||||
8
e2e/fixtures/docs-only-pr/scenario.json
Normal file
8
e2e/fixtures/docs-only-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "docs-only-pr",
|
||||
"description": "纯文档变更",
|
||||
"expectedTriageMode": "skip",
|
||||
"expectedDomains": [],
|
||||
"minFindings": 0,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
22
e2e/fixtures/duplicate-webhook-pr/base/src/auth.ts
Normal file
22
e2e/fixtures/duplicate-webhook-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
20
e2e/fixtures/duplicate-webhook-pr/branch/src/user-handler.ts
Normal file
20
e2e/fixtures/duplicate-webhook-pr/branch/src/user-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
9
e2e/fixtures/duplicate-webhook-pr/scenario.json
Normal file
9
e2e/fixtures/duplicate-webhook-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "duplicate-webhook-pr",
|
||||
"description": "重复webhook幂等性测试",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0,
|
||||
"testIdempotency": true
|
||||
}
|
||||
15
e2e/fixtures/minimal-change-pr/base/src/utils.ts
Normal file
15
e2e/fixtures/minimal-change-pr/base/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score > 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
15
e2e/fixtures/minimal-change-pr/branch/src/utils.ts
Normal file
15
e2e/fixtures/minimal-change-pr/branch/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score >= 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
9
e2e/fixtures/minimal-change-pr/scenario.json
Normal file
9
e2e/fixtures/minimal-change-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "minimal-change-pr",
|
||||
"description": "单文件微量变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 3,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
12
e2e/fixtures/security-pr/base/src/auth.ts
Normal file
12
e2e/fixtures/security-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && token === expectedToken;
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
14
e2e/fixtures/security-pr/branch/src/auth.ts
Normal file
14
e2e/fixtures/security-pr/branch/src/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const FALLBACK_ADMIN_TOKEN = 'admin-super-secret-token';
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && (token === expectedToken || token === FALLBACK_ADMIN_TOKEN);
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
8
e2e/fixtures/security-pr/scenario.json
Normal file
8
e2e/fixtures/security-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "security-pr",
|
||||
"description": "安全相关变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
22
e2e/fixtures/simple-bug-pr/base/src/auth.ts
Normal file
22
e2e/fixtures/simple-bug-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
22
e2e/fixtures/simple-bug-pr/branch/src/auth.ts
Normal file
22
e2e/fixtures/simple-bug-pr/branch/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
39
e2e/fixtures/simple-bug-pr/branch/src/user-handler.ts
Normal file
39
e2e/fixtures/simple-bug-pr/branch/src/user-handler.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { User } from './auth';
|
||||
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function validateUserRole(user: User | null, requiredRole: string): boolean {
|
||||
const hardcodedSecret = 'sk-abc123secretkey456';
|
||||
if (hardcodedSecret) {
|
||||
return user?.role === requiredRole;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function deleteUser(users: Map<string, User>, userId: string): Map<string, User> {
|
||||
const user = users.get(userId);
|
||||
if (user!.role === 'admin') {
|
||||
throw new Error('Cannot delete admin user');
|
||||
}
|
||||
users.delete(userId);
|
||||
return users;
|
||||
}
|
||||
8
e2e/fixtures/simple-bug-pr/scenario.json
Normal file
8
e2e/fixtures/simple-bug-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "simple-bug-pr",
|
||||
"description": "包含空指针、SQL注入、硬编码密钥的PR",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 2,
|
||||
"minHighSeverity": 1
|
||||
}
|
||||
104
e2e/llm-mock.test.ts
Normal file
104
e2e/llm-mock.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createMockChatForRole, isE2EMockActive } from './llm-mock';
|
||||
|
||||
describe('LLM Mock', () => {
|
||||
test('specialist role returns preset findings', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('specialist', {
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a code reviewer' },
|
||||
{ role: 'user', content: 'Review this code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.finishReason).toBe('stop');
|
||||
expect(response.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.findings).toBeDefined();
|
||||
expect(parsed.findings.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.findings[0].severity).toBe('high');
|
||||
expect(parsed.findings[0].path).toBe('src/user-handler.ts');
|
||||
});
|
||||
|
||||
test('specialist role simulates autonomous search and cross-file reads when tools are available', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const tools = [
|
||||
{
|
||||
name: 'search_code',
|
||||
description: 'search',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{ name: 'read_file', description: 'read', parameters: { type: 'object', properties: {} } },
|
||||
];
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are a code reviewer' },
|
||||
{ role: 'user' as const, content: 'Review this code' },
|
||||
];
|
||||
|
||||
const turn1 = await mock('specialist', { messages, tools });
|
||||
expect(turn1.finishReason).toBe('tool_calls');
|
||||
expect(turn1.toolCalls.map((toolCall) => toolCall.name)).toEqual(['search_code']);
|
||||
|
||||
const turn2 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'assistant', content: '', toolCalls: turn1.toolCalls },
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn2.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn2.toolCalls[0].arguments)).toEqual({ file_path: 'src/user-handler.ts' });
|
||||
|
||||
const turn3 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn3.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn3.toolCalls[0].arguments)).toEqual({ file_path: 'src/auth.ts' });
|
||||
|
||||
const turn4 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_callee', content: '{"path":"src/auth.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn4.finishReason).toBe('stop');
|
||||
expect(turn4.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(turn4.content!);
|
||||
expect(parsed.findings[0].detail).toContain('auth/user model');
|
||||
expect(parsed.findings[0].evidence).toContain('src/auth.ts');
|
||||
});
|
||||
|
||||
test('planner role returns preset summary', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('planner', {
|
||||
messages: [{ role: 'user', content: 'Summarize this diff' }],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.summary).toBeDefined();
|
||||
expect(parsed.keyConcerns).toBeDefined();
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns true when E2E_MOCK_LLM=1', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = '1';
|
||||
expect(isE2EMockActive()).toBe(true);
|
||||
process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns false when E2E_MOCK_LLM is not set', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = undefined;
|
||||
expect(isE2EMockActive()).toBe(false);
|
||||
if (orig !== undefined) process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
});
|
||||
1
e2e/llm-mock.ts
Normal file
1
e2e/llm-mock.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createMockChatForRole, isE2EMockActive } from '../src/llm/e2e-mock';
|
||||
60
e2e/seed.sh
60
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 \
|
||||
docker exec -u git e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
--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,51 @@ 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}/api/health" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
break
|
||||
fi
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Login to get JWT (正确路径: /admin/api/login)
|
||||
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 设置..."
|
||||
|
||||
# 逐项配置(避免 JSON 格式化问题)
|
||||
set_assistant_config() {
|
||||
local key="$1" value="$2"
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{\"${key}\": \"${value}\"}" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
set_assistant_config "WEBHOOK_SECRET" "${WEBHOOK_SECRET}"
|
||||
set_assistant_config "GITEA_API_URL" "http://gitea:3000/api/v1"
|
||||
set_assistant_config "GITEA_ACCESS_TOKEN" "${GITEA_TOKEN}"
|
||||
set_assistant_config "REVIEW_ENGINE" "kernel"
|
||||
set_assistant_config "REVIEW_ENABLE_HUMAN_GATE" "false"
|
||||
set_assistant_config "REVIEW_ALLOWED_COMMANDS" "git,rg,cat,sed,wc"
|
||||
set_assistant_config "REVIEW_COMMAND_TIMEOUT_MS" "30000"
|
||||
|
||||
echo " Assistant 配置完成(含 Gitea 连接参数)"
|
||||
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 +168,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" \
|
||||
@@ -170,6 +213,5 @@ echo " PR: #${PR_NUMBER}"
|
||||
echo " Token: ${GITEA_TOKEN:0:8}..."
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 更新 assistant 容器的 GITEA_ACCESS_TOKEN:"
|
||||
echo " E2E_GITEA_TOKEN=${GITEA_TOKEN} docker compose -f docker-compose.e2e.yml up -d assistant"
|
||||
echo " 2. 运行测试: ./e2e/test.sh"
|
||||
echo " 1. 触发 PR webhook 或推送 feature 分支新提交"
|
||||
echo " 2. 运行 E2E 测试: bun run test:e2e"
|
||||
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
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
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
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();
|
||||
@@ -2,9 +2,14 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
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>
|
||||
@@ -43,15 +50,26 @@ function App() {
|
||||
</AuthGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<Route index element={<Navigate to="/sessions" replace />} />
|
||||
<Route path="sessions" element={<ReviewSessionsPage />} />
|
||||
<Route path="repos" element={<RepositoryManager />} />
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="*" element={<Navigate to="/sessions" 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
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
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
365
frontend/src/components/ReviewConfigPage.tsx
Normal file
365
frontend/src/components/ReviewConfigPage.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
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 = 'kernel' | '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',
|
||||
]);
|
||||
|
||||
const KERNEL_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
'ENABLE_TRIAGE',
|
||||
]);
|
||||
|
||||
/** 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 'kernel':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || KERNEL_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: 'kernel', label: 'Kernel', description: 'PR Session + Agentic Loop 审查' },
|
||||
{ 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 === 'kernel' || val === 'codex') return val;
|
||||
return 'kernel';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
|
||||
// Initialize local config from review group
|
||||
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> = {};
|
||||
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 handleResetAll = () => {
|
||||
const allOverrideKeys = (reviewGroup?.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(() => {
|
||||
return (reviewGroup?.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 审查设置' : 'Kernel 审查设置',
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '基于 PR Session 的 agentic loop 审查引擎配置',
|
||||
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 === 'kernel' && (
|
||||
<>
|
||||
<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
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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
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
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
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
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>
|
||||
);
|
||||
}
|
||||
266
frontend/src/components/llm/ProviderList.tsx
Normal file
266
frontend/src/components/llm/ProviderList.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
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, fetchRoles
|
||||
} 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 { data: roles = [] } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
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'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
},
|
||||
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) => {
|
||||
const boundRoles = roles.filter(r => r.providerId === provider.id);
|
||||
if (boundRoles.length > 0) {
|
||||
const roleNames = boundRoles.map(r => r.role).join(', ');
|
||||
if (!window.confirm(`警告:该提供商已绑定到以下角色 (${roleNames})。\n删除后这些角色将失去提供商配置!\n确定要删除吗?`)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
393
frontend/src/components/llm/RoleAssignment.tsx
Normal file
393
frontend/src/components/llm/RoleAssignment.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Route, Save, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
|
||||
import {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
type KernelSubagentDto,
|
||||
} from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: 'Planner', desc: '用于 triage / planning / context compression,负责审查分流与上下文压缩' },
|
||||
specialist: { label: 'Specialist', desc: '用于 correctness / security / quality 等深度审查' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function getModelRoleBadgeClass(modelRole?: string): string {
|
||||
switch (modelRole) {
|
||||
case 'planner':
|
||||
return 'border-info/30 bg-info/10 text-info';
|
||||
case 'specialist':
|
||||
return 'border-primary/30 bg-primary/10 text-primary';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceBadgeClass(source: KernelSubagentDto['source']): string {
|
||||
switch (source) {
|
||||
case 'built-in':
|
||||
return 'border-primary/20 bg-primary/10 text-primary';
|
||||
case 'plugin':
|
||||
return 'border-warning/20 bg-warning/10 text-warning';
|
||||
case 'custom':
|
||||
return 'border-success/20 bg-success/10 text-success';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
|
||||
|
||||
const { data: providers = [] } = useQuery({
|
||||
queryKey: ['llm-providers'],
|
||||
queryFn: fetchProviders,
|
||||
});
|
||||
|
||||
const { data: roles = [], isLoading } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
const { data: subagents = [], isLoading: isSubagentsLoading } = useQuery({
|
||||
queryKey: ['kernel-subagents'],
|
||||
queryFn: fetchKernelSubagents,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length > 0) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
roles.forEach(role => {
|
||||
initial[role.role] = {
|
||||
providerId: role.providerId,
|
||||
model: role.model || '',
|
||||
};
|
||||
});
|
||||
ROLES.forEach(r => {
|
||||
if (!initial[r]) {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
}
|
||||
});
|
||||
setRoleStates(initial);
|
||||
} else if (!isLoading) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
ROLES.forEach(r => {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
});
|
||||
setRoleStates(initial);
|
||||
}
|
||||
}, [roles, isLoading]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
|
||||
return setRole(role, providerId, model);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleProviderChange = (role: string, providerId: string) => {
|
||||
const provider = providers.find(p => p.id === providerId);
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: {
|
||||
providerId,
|
||||
model: provider?.defaultModel || ''
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleModelChange = (role: string, model: string) => {
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], model }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = (role: string) => {
|
||||
const state = roleStates[role];
|
||||
if (!state.providerId) {
|
||||
return toast.error('请选择提供商');
|
||||
}
|
||||
if (!state.model) {
|
||||
return toast.error('请输入模型名称');
|
||||
}
|
||||
saveMutation.mutate({
|
||||
role,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
});
|
||||
};
|
||||
|
||||
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
|
||||
|
||||
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-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
Subagents 与模型路由
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
上层展示 subagent 目录,下层配置 Planner / Specialist 模型路由
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="theme-card-content space-y-8">
|
||||
{/* ── Subagents 目录 ──────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">Subagents 目录</h3>
|
||||
</div>
|
||||
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
<AlertTitle>流程编排由 kernel 自动驱动</AlertTitle>
|
||||
<AlertDescription>
|
||||
kernel 根据 session state 与 planner 选择注册式 subagent 执行。下方展示的是当前已注册的 subagent 及其能力标签。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isSubagentsLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载 subagent 目录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Subagents</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">{subagents.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Built-in</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{subagents.filter((item) => item.source === 'built-in').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">模型角色</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{new Set(subagents.map((item) => item.modelRole).filter(Boolean)).size}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-5">Subagent</TableHead>
|
||||
<TableHead>能力定位</TableHead>
|
||||
<TableHead>模型角色</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead className="pr-5 text-right">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subagents.map((subagent) => (
|
||||
<TableRow key={subagent.name}>
|
||||
<TableCell className="pl-5 align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{subagent.name}</span>
|
||||
<Badge className={getSourceBadgeClass(subagent.source)}>{subagent.source}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{subagent.description}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm text-muted-foreground whitespace-normal">
|
||||
{subagent.whenToUse}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge className={getModelRoleBadgeClass(subagent.modelRole)}>
|
||||
<Route className="h-3 w-3" />
|
||||
{subagent.modelRole ?? '未绑定'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-wrap gap-1.5 max-w-[260px]">
|
||||
{subagent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="bg-muted/30">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="pr-5 align-top text-right">
|
||||
<Badge className={subagent.resumable ? 'border-success/20 bg-success/10 text-success' : 'border-border bg-muted/40 text-muted-foreground'}>
|
||||
{subagent.resumable ? '可恢复' : '一次性'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── 模型角色路由 ─────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-warning/25 bg-warning/10 text-warning">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">模型角色路由</h3>
|
||||
</div>
|
||||
|
||||
<Alert className="border-warning/20 bg-warning/5">
|
||||
<ShieldCheck className="h-4 w-4 text-warning" />
|
||||
<AlertTitle>这里配置的是底层模型路由,不是流程角色编排</AlertTitle>
|
||||
<AlertDescription>
|
||||
Planner / Specialist 决定由哪个 provider/model 响应 LLM 调用。subagent 的注册、标签和执行顺序由 kernel 控制。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载模型角色路由...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
const consumers = subagents.filter((item) => item.modelRole === role);
|
||||
|
||||
return (
|
||||
<div key={role} className="py-5 px-1">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/60 bg-card/40 p-4 hover:bg-accent/20 transition-colors">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<Badge variant="outline" className="bg-muted/30">
|
||||
{consumers.length} 个 subagent
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
{consumers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{consumers.map((item) => (
|
||||
<Badge key={item.name} className="border-primary/15 bg-primary/5 text-primary">
|
||||
{item.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/llm/TestResultDialog.tsx
Normal file
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
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
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();
|
||||
});
|
||||
});
|
||||
90
frontend/src/components/llm/__tests__/ProviderList.test.tsx
Normal file
90
frontend/src/components/llm/__tests__/ProviderList.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
fetchRoles,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
} from '@/services/llmProviderService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', () => ({
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: 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(fetchRoles).mockResolvedValueOnce([]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
128
frontend/src/components/llm/__tests__/RoleAssignment.test.tsx
Normal file
128
frontend/src/components/llm/__tests__/RoleAssignment.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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 {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
} from '@/services/llmProviderService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
|
||||
return {
|
||||
...actual,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchKernelSubagents: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
setRole: vi.fn(),
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders subagent directory and model role routing', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'OpenAI',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchKernelSubagents).mockResolvedValueOnce([
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:triage',
|
||||
source: 'built-in',
|
||||
description: '根据变更范围决定 review 域与审查模式',
|
||||
whenToUse: '当需要规划任务时',
|
||||
modelRole: 'planner',
|
||||
tags: ['review', 'planner', 'triage'],
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:full_review',
|
||||
source: 'built-in',
|
||||
description: '执行一次完整自主代码审查',
|
||||
whenToUse: '当 triage 生成审查提示后执行完整审查',
|
||||
modelRole: 'specialist',
|
||||
tags: ['review', 'specialist', 'full-review', 'autonomous-review'],
|
||||
resumable: true,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(setRole).mockResolvedValue({
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'custom-planner-model',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
expect(await screen.findByText('Subagents 与模型路由')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('review:triage')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('模型角色路由')).toBeInTheDocument();
|
||||
expect(screen.getByText('Planner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Specialist')).toBeInTheDocument();
|
||||
|
||||
const providerPlaceholders = screen.getAllByText('选择提供商');
|
||||
const triggerButton = providerPlaceholders[0].closest('button')!;
|
||||
await user.click(triggerButton);
|
||||
await user.click(await screen.findByRole('option', { name: /OpenAI/ }));
|
||||
|
||||
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
|
||||
await waitFor(() => {
|
||||
expect(modelInputs[0].value).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
await user.clear(modelInputs[0]);
|
||||
await user.type(modelInputs[0], 'custom-planner-model');
|
||||
expect(modelInputs[0].value).toBe('custom-planner-model');
|
||||
});
|
||||
});
|
||||
@@ -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
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
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
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;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user