59 Commits

Author SHA1 Message Date
semantic-release-bot
b10b8dd7d5 chore(release): 1.1.1 [skip ci]
## [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](5aeff7585b))
2026-03-24 04:54:36 +00:00
jeffusion
5aeff7585b fix(build): guard husky prepare for production installs 2026-03-24 12:53:54 +08:00
semantic-release-bot
3307ec687e chore(release): 1.1.0 [skip ci]
# [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](f410373f7b))
* **agent:** improve specialist agent JSON resilience and finding schema ([2587576](2587576514))
* **ci:** stabilize visual regression environment ([9887504](98875044d6))
* **config:** make persistOverrides resilient to read-only filesystems ([3f2817d](3f2817d6c3))
* **config:** silently skip readonly fields on save instead of rejecting ([12425d1](12425d147f))
* **docker:** add git, ca-certificates, and ripgrep to production image ([ba26635](ba2663552d))
* **frontend:** standardize favicon/title, 401 redirect, SPA root route, and theme switching ([5bb1c3a](5bb1c3a2d1))
* **k8s:** extract Secret to separate file to fix kustomize apply ([e3b8365](e3b8365ea2))
* **k8s:** remove stale GITEA_ACCESS_TOKEN/GITEA_API_URL/QDRANT_URL from k8s config ([9b063af](9b063afba0))
* **k8s:** use writable emptyDir volume for config overrides ([98e5048](98e5048f2c))
* **lint:** resolve biome violations across src modules ([3c1d616](3c1d616dc1))
* make all config consumers read dynamically instead of caching at module load ([9a356a2](9a356a228f))
* make FEISHU_WEBHOOK_URL optional to prevent startup crash ([d84a0ed](d84a0ed956))
* remove isDev branches that caused production to use mock test data ([f3ba9de](f3ba9de06f))
* **test:** update specialist-agent-react tests for LLMGateway API ([824564d](824564dac6))
* **ui:** align card headers and stabilize themed layout polish ([28d86af](28d86aff16))

### Features

* **config:** add Codex engine configuration fields ([129094a](129094a39e))
* **config:** add global prompt setting injected into all LLM calls ([afd5685](afd568588d))
* **config:** migrate all runtime settings from env vars to SQLite DB ([4c32a46](4c32a460d3))
* **db:** add SQLite database layer with encrypted secret storage ([21fef99](21fef999fb))
* **frontend:** update config UI for DB-first config architecture ([9c9ef05](9c9ef05d13))
* **llm:** add LLM config REST API controller ([c6c8e20](c6c8e20683))
* **llm:** add pluggable multi-provider LLM architecture ([c9a2db3](c9a2db3df2))
* **llm:** add resilience layer with rate limiting and retry ([839d4a8](839d4a89bf))
* **review/codex:** add Codex review engine with MCP tools ([614f66c](614f66c433))
* **review:** add incremental review with snapshot refs ([9308c60](9308c60aa0))
* **review:** add token-aware context control with tokenlens ([ec2029a](ec2029a942))
* **review:** add triage agent for smart specialist routing ([86480de](86480dec07))
* **review:** add workspace cleanup on PR close and scheduled stale cleanup ([792ed7f](792ed7faa2))
* **review:** remove legacy mode and harden agent/codex pipeline ([1c0c9af](1c0c9afd17))
* **ui:** add frontend test infrastructure with vitest ([bc7616d](bc7616df42))
* **ui:** add LLM provider management frontend ([c45cb34](c45cb34a35))
* **ui:** add review config page with engine selector ([ae0dfce](ae0dfceba1))
* **ui:** replace hardcoded model lists with dynamic tokenlens API ([71bd310](71bd310459))
2026-03-24 04:30:56 +00:00
jeffusion
98875044d6 fix(ci): stabilize visual regression environment 2026-03-24 12:30:13 +08:00
jeffusion
bd8235c70f chore(husky): enforce staged biome pre-commit check 2026-03-24 12:30:13 +08:00
jeffusion
3c1d616dc1 fix(lint): resolve biome violations across src modules 2026-03-24 12:30:13 +08:00
jeffusion
28d86aff16 fix(ui): align card headers and stabilize themed layout polish 2026-03-24 12:30:13 +08:00
jeffusion
1c0c9afd17 feat(review): remove legacy mode and harden agent/codex pipeline
Drop legacy runtime paths and role assignments across backend/frontend, and add upgrade-safe DB migration for existing installs. This aligns config, docs, tests, and UI to the agent-first architecture with codex as the only alternate engine.
2026-03-24 12:30:13 +08:00
jeffusion
5bb1c3a2d1 fix(frontend): standardize favicon/title, 401 redirect, SPA root route, and theme switching
- Replace default Vite favicon and title with project-specific branding
- Add axios response interceptor to handle 401 by clearing token and redirecting to login
- Move health check endpoint from '/' to '/api/health' so SPA index.html is served on root
- Integrate next-themes ThemeProvider with system preference detection and manual toggle
- Update docker-compose and k8s health check paths accordingly
- Replace hardcoded dark-only colors with semantic CSS variable tokens for theme compatibility
2026-03-24 12:30:13 +08:00
jeffusion
2d4f670365 test: add unit tests for incremental review, codex engine, MCP tools, and cleanup
- LocalRepoManager: snapshot ref CRUD, getMirrorPath, cleanStaleMirrors (real git)
- DiffExtractor: incremental two-dot vs three-dot diff, token clipping (real git)
- Orchestrator + CodexRunner: incremental baseline resolution, rebase fallback
- McpToolExecutor: context management, tool dispatch, JSON-RPC handler routes
- CleanupScheduler: start/stop lifecycle, idempotency, scheduling logic
- Config schema: Codex field definitions (API URL, key, model, timeout, prompt)
2026-03-24 12:30:13 +08:00
jeffusion
792ed7faa2 feat(review): add workspace cleanup on PR close and scheduled stale cleanup
- Delete snapshot refs (refs/reviewed/pr/{n}/*) when PR is closed or merged
- Add daily 2:00 AM scheduled cleanup for mirrors/workspaces older than 3 days
- Expose deleteReviewedRefs, getMirrorPath, cleanStaleMirrors on LocalRepoManager
2026-03-24 12:30:13 +08:00
jeffusion
272c832c43 build(docker): add Codex CLI to Docker image
Install Node.js 22 and @openai/codex globally in the production Docker
image to support the Codex review engine runtime dependency.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
ae0dfceba1 feat(ui): add review config page with engine selector
Add ReviewConfigPage with engine selector (legacy/agent/codex) and
Codex-specific configuration fields. Restructure sidebar navigation
to separate review settings from general config. Update ConfigGroupCard
with improved styling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
129094a39e feat(config): add Codex engine configuration fields
Add CODEX_API_URL, CODEX_API_KEY, CODEX_MODEL, CODEX_TIMEOUT_MS, and
CODEX_REVIEW_PROMPT to config schema and manager. Wire Codex engine
dispatch in review controller alongside agent/legacy engines. Register
MCP Streamable HTTP endpoint at /mcp/gitea-review in app entry point.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
9308c60aa0 feat(review): add incremental review with snapshot refs
Save baseSha + headSha as git refs (refs/reviewed/pr/{n}/base and
refs/reviewed/pr/{n}/head) after each successful PR review. On
subsequent reviews, compare saved baseSha with current baseSha to
decide incremental (two-dot diff) vs full (three-dot diff). Falls
back to full review only when PR base changes (rebase scenario).
Protects custom refs from fetch --prune via negative refspec.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
614f66c433 feat(review/codex): add Codex review engine with MCP tools
Add a new Codex-based review engine that runs OpenAI Codex CLI in
full-auto mode with a Streamable HTTP MCP server providing Gitea
review tools (get_pr_info, add_review_comment, add_review_summary,
get_file_content). Includes incremental review support via
lastReviewedHead in MCP context and review prompt.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
fdfd49be63 refactor(ui): use tokenlens as sole model source, remove provider listModels
Remove the per-provider listModels API (GET /providers/:id/models) and all
four provider implementations (OpenAI Compatible, OpenAI Responses, Anthropic,
Gemini). ModelCombobox now only shows tokenlens suggestions (tagged '推荐') plus
free-form custom input — no more unfiltered 'API' models from provider SDKs.

Fixes: switching provider type in ProviderDialog no longer shows stale models
from the original provider's API.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
71bd310459 feat(ui): replace hardcoded model lists with dynamic tokenlens API
Add GET /llm/model-suggestions endpoint that maps ProviderType to models.dev
provider keys and returns chat model IDs from the tokenlens catalog. Lazy-loads
catalog on first request to avoid empty results when engine hasn't started.

Frontend ModelCombobox now fetches suggestions via useQuery with 30min cache
instead of reading from hardcoded MODEL_SUGGESTIONS constant.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
ec2029a942 feat(review): add token-aware context control with tokenlens
Replace hardcoded char-count context limits with token-based budgets using
tokenlens (data from models.dev). TokenCounter provides 3-tier context window
lookup: dynamic catalog (refreshed every 24h) → static tokenlens → 128k default.

- specialist-agent: token budget from model context window instead of MAX_CONTEXT_CHARS=100k
- critic-agent/reflexion-agent: tokenCounter.clip() instead of diff.slice(0, 3000/2000)
- diff-extractor: raw diff clipping at 30k tokens
- engine.ts: refreshCatalog() at startup, stopRefresh() at shutdown

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
86480dec07 feat(review): add triage agent for smart specialist routing
Implement TriageAgent with heuristic fast path (skip trivial changes like
lockfiles, CI configs, docs-only) and LLM fallback via chatForRole('planner').
Orchestrator now runs triage before specialist dispatch, only invoking agents
for relevant domains instead of all 4 specialists on every change.

Uses the pre-reserved 'planner' model role that was defined in DB schema and
frontend UI but never wired to backend logic.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
839d4a89bf feat(llm): add resilience layer with rate limiting and retry
Add LLMSemaphore for concurrency control (default 4) and retryWithBackoff
with exponential backoff respecting 429 retryAfterSeconds. Wrap all
LLMGateway calls (chatForRole, chatDirect, embedForRole) via withResilience.

New config fields: LLM_MAX_CONCURRENT_CALLS, LLM_RETRY_MAX_ATTEMPTS,
LLM_RETRY_BASE_DELAY_MS, ENABLE_TRIAGE.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
9a356a228f fix: make all config consumers read dynamically instead of caching at module load
After migrating config to DB, values changed via Web UI were not picked
up by consumers that cached config at module load time.

- gitea.ts: replace static axios.create() with request interceptors that
  read config.gitea.apiUrl and accessToken on every request
- feishu.ts: remove constructor caching of webhookUrl/webhookSecret,
  read from config.feishu.* on each sendMessage() call
- engine.ts: create SandboxExec/LocalRepoManager/DiffExtractor/Orchestrator
  per review run instead of once at class init, so workdir/token/limits
  always reflect current config. FileReviewStore stays singleton (has state).
- index.ts: wrap JWT middleware in per-request handler so config.admin.jwtSecret
  is read dynamically instead of captured once at startup
2026-03-24 12:30:13 +08:00
jeffusion
e3b8365ea2 fix(k8s): extract Secret to separate file to fix kustomize apply
- Move ENCRYPTION_KEY Secret from gitea-assistant.yaml to k8s/secret.yaml
- Add secret.yaml to kustomization.yaml resources
- Update deployment docs with secret creation step
2026-03-24 12:30:13 +08:00
jeffusion
0bc147cbc5 refactor: replace master.key file with ENCRYPTION_KEY env var and fix k8s deployment
- Replace file-based master key (data/master.key) with ENCRYPTION_KEY env var (hex-encoded)
- App now requires ENCRYPTION_KEY to start, removing MASTER_KEY_PATH entirely
- Fix k8s: add missing gitea-assistant-data volume, replace PVC with hostPath for single-node
- Fix k8s: change qdrant from StatefulSet+PVC to Deployment+hostPath
- Add K8s Secret for ENCRYPTION_KEY injection
- Update all tests, .env.example, and documentation
2026-03-24 12:30:13 +08:00
jeffusion
9b063afba0 fix(k8s): remove stale GITEA_ACCESS_TOKEN/GITEA_API_URL/QDRANT_URL from k8s config
These env vars are no longer read by the application — all runtime
settings are managed through the Admin Dashboard Web UI backed by
SQLite. Only PORT remains in the ConfigMap. Secret resource removed
entirely. README k8s sections updated accordingly.
2026-03-24 12:30:13 +08:00
jeffusion
7ef35fa8ee chore(deploy): remove obsolete env vars from deployment configs
- docker-compose.e2e.yml: remove WEBHOOK_SECRET, REVIEW_* env vars
  (now configured via assistant API in seed.sh)
- e2e/seed.sh: add step to configure assistant via Admin API after boot
  (login with default password, set webhook secret + review settings)
- k8s/gitea-assistant.yaml: Secret now only contains GITEA_ACCESS_TOKEN;
  ConfigMap reduced to GITEA_API_URL, PORT, QDRANT_URL
- cursor rules updated to document DB-first config architecture
2026-03-24 12:30:13 +08:00
jeffusion
769517f7bf docs: update README to reflect DB-first configuration model
- Configuration Reference now shows only PORT/DATABASE_PATH/MASTER_KEY_PATH as env vars
- All other settings documented as Web UI configuration
- Installation steps simplified (no more .env editing for runtime config)
- Docker run command updated to use volume mount instead of --env-file
- k8s section simplified: only GITEA_ACCESS_TOKEN in Secret
2026-03-24 12:30:13 +08:00
jeffusion
7a775ee9c5 test(config): rewrite config-manager tests for DB-backed architecture
22 tests covering: getCurrent() defaults, setOverrides/getSource,
resetKeys, seedDefaults, and type conversions. Uses initDatabase()/
closeDatabase() pattern with isolated temp dirs per test.
2026-03-24 12:30:13 +08:00
jeffusion
9c9ef05d13 feat(frontend): update config UI for DB-first config architecture
- ConfigSource type: 'default' | 'db' (removed 'env')
- Badge: 'db' shows '已配置', 'default' shows '默认值'
- Removed readonly field lock icon and env-var-only warning message
- Updated 'override' → 'db' references in ConfigGroupCard and ConfigManager
- Removed readonly/readonlyWarning from ConfigFieldDto interface
2026-03-24 12:30:13 +08:00
jeffusion
4c32a460d3 feat(config): migrate all runtime settings from env vars to SQLite DB
Replace env-var based config with DB-first approach (Portainer model).
Only PORT, DATABASE_PATH, and MASTER_KEY_PATH remain as env vars.
All other settings (Gitea, Feishu, security, review engine, memory) are
managed through the Admin Dashboard Web UI backed by system_settings table.

- ConfigManager rewrites getRawValue() to read from settingsRepo with
  fallback to compiled-in defaults (no more process.env reads)
- seedDefaults() auto-generates JWT_SECRET and WEBHOOK_SECRET on first boot
- getSource() returns 'db' | 'default' (removed 'env' source type)
- Merged 'app'+'admin' config groups into 'security' group
- Removed PORT from CONFIG_FIELDS (env-var only)
- Removed readonly/readonlyWarning from all field definitions
2026-03-24 12:30:13 +08:00
jeffusion
9d986f4b5a chore(cursor): update IDE rules for multi-provider LLM architecture
Add llm/, db/, crypto/ dirs to structure; replace OpenAI-only references with LLM Gateway.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
851c73e326 chore(k8s): remove obsolete OpenAI env vars and add PVC for data
Remove OPENAI_API_KEY from Secret, OPENAI_*/REVIEW_MODEL_*/CONFIG_OVERRIDES_PATH from ConfigMap; switch emptyDir to PVC.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
07719e940a chore(docker): update compose files for new LLM data volume
Replace config-overrides.json mount with assistant_data volume; remove OPENAI_* env vars from e2e.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
b807c10d7a chore: update .env.example for multi-provider LLM configuration
Remove obsolete OPENAI_* and REVIEW_MODEL_* vars; add note about Web UI config.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
8a8b336237 docs: update README and Chinese docs for multi-provider LLM architecture
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
3a3708b147 chore(deps): upgrade bun to 1.3.10 and regenerate lockfiles
Upgrade local bun from 1.2.22 to 1.3.10 to match oven/bun:1 Docker image.
Revert Dockerfile from pinned bun:1.2 back to bun:1 (latest). Regenerate
both root and frontend bun.lock with bun 1.3.10 for consistent dependency
resolution between local development and Docker builds.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
efc2753e45 docs(design): add pluggable LLM providers design document
Comprehensive 838-line design specification covering architecture,
provider types, database schema, API endpoints, encryption strategy,
frontend wireframes, and migration plan for the pluggable multi-provider
LLM system.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
824564dac6 fix(test): update specialist-agent-react tests for LLMGateway API
Fix 13 pre-existing test failures caused by SpecialistAgent constructor
signature change during LLMGateway migration. Replace raw OpenAI client
mock with gateway mock returning normalized LLMChatResponse objects.
Update assertions for gateway request format (responseFormat, providerOptions)
and LLMMessage shape (toolCallId instead of tool_call_id).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
31af14a2ca test(ui): add frontend component tests for LLM management UI (7 tests)
Component tests for all LLM management UI elements using vitest and
@testing-library/react with happy-dom:
- LLMProviders: Tab container rendering
- ModelCombobox: API/recommended/custom tag display, selection, custom input
- ProviderList: Async data loading, enable switches, status indicators
- RoleAssignment: Role card rendering, Radix Select interaction
- TestResultDialog: Success/error state rendering

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
3937c678f3 test(llm): add backend unit tests for LLM provider feature (113 tests)
Comprehensive test coverage for the entire LLM provider backend:
- secrets.test.ts: AES-256-GCM encrypt/decrypt, master key lifecycle (14)
- tool-converter.test.ts: Cross-provider tool format conversion (10)
- gateway.test.ts: Role routing, error handling, cache invalidation (12)
- provider-repo.test.ts: Provider CRUD, filtering, timestamps (18)
- model-role-repo.test.ts: Role assignments, FK constraints (15)
- secret-repo.test.ts: Encrypted storage, CASCADE delete (13)
- llm-config.test.ts: Full REST API integration tests (31)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
bc7616df42 feat(ui): add frontend test infrastructure with vitest
Install vitest, @testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, and happy-dom as dev dependencies. Configure
vitest with happy-dom environment, path aliases, and test setup file.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
c45cb34a35 feat(ui): add LLM provider management frontend
Add complete Web UI for LLM provider configuration: provider list with
enable/disable toggles, add/edit dialog, connection testing with result
display, role assignment cards, and model combobox with API/recommended/custom
tags. All labels in Chinese. Add description prop to SelectItem for
Radix Select rendering fix. Register route and nav link.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
984cf734fe refactor(config): remove LLM settings from config layer
Strip OpenAI-specific settings (apiKey, baseUrl, model) and per-role model
overrides from config schema — these are now managed through the database
via the LLM provider UI. Simplify config-manager and its tests accordingly.
Keep only runtime settings (port, webhookSecret, etc.) in env/config.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
0bb6cf7849 refactor(review): migrate review agents from direct OpenAI to LLMGateway
Replace all direct OpenAI client usage in review agents, orchestrator,
learning system, and AI review service with the new LLMGateway abstraction.
Agents now call gateway.chatForRole() instead of openai.chat.completions.create(),
enabling multi-provider support across all review workflows. Add getAll()
method to ToolRegistry for provider capability checking.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
c6c8e20683 feat(llm): add LLM config REST API controller
Add REST endpoints under /admin/api/llm/ for provider CRUD, API key
management, role assignments, connection testing, and model listing.
Register routes in index.ts with JWT authentication middleware. Initialize
master key and database on server startup.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
21fef999fb feat(db): add SQLite database layer with encrypted secret storage
Add bun:sqlite-based database with automatic migration system. Includes
repositories for LLM providers (CRUD), model-role assignments, encrypted
API key secrets (AES-256-GCM via master.key), and system settings.
Single-file DB at data/assistant.db.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
c9a2db3df2 feat(llm): add pluggable multi-provider LLM architecture
Introduce provider-agnostic LLM gateway supporting 4 provider types:
OpenAI Compatible, OpenAI Responses API, Anthropic Messages API, and
Google Gemini API. Each provider normalizes to a unified LLMChatResponse
format with tool call support. Includes AES-256-GCM encrypted secret
management for API keys and a tool-converter for cross-provider tool
format translation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
f0d981ad00 chore: update .gitignore and add bunfig.toml for test isolation
Exclude runtime data (data/), SQLite WAL files, frontend lock file, and
build artifacts. Add bunfig.toml to scope bun test to src/ only,
preventing it from picking up frontend vitest test files.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
afd568588d feat(config): add global prompt setting injected into all LLM calls
Add GLOBAL_PROMPT config field that appends user-defined instructions to
every LLM system message across all 9 call sites (legacy engine, agent
specialist, reflexion, critic, and debate orchestrator).

Configured via admin dashboard (auto-rendered from CONFIG_FIELDS metadata)
or GLOBAL_PROMPT env var. Example use: "请始终使用中文回复".

Changes:
- Add GLOBAL_PROMPT to Zod schema, AppConfig interface, and buildConfig
- Add CONFIG_FIELDS metadata (group: openai, type: text)
- Add getEffectiveValue switch case
- Add withGlobalPrompt() helper in src/utils/global-prompt.ts
- Inject into all LLM call sites via withGlobalPrompt wrapper

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
98e5048f2c fix(k8s): use writable emptyDir volume for config overrides
ConfigMap volumes are read-only in K8s, causing EROFS when saving config.
Replace ConfigMap-mounted config-overrides.json with a writable emptyDir
at /app/data/ and set CONFIG_OVERRIDES_PATH accordingly. The app handles
missing override files gracefully (starts with empty overrides).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
12425d147f fix(config): silently skip readonly fields on save instead of rejecting
Frontend sends entire form state including readonly fields (PORT,
WEBHOOK_SECRET, JWT_SECRET). Previously the backend rejected the whole
request. Now readonly fields are silently skipped.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
3f2817d6c3 fix(config): make persistOverrides resilient to read-only filesystems
Atomic rename (temp→target) fails on K8s volumes with EBUSY/EXDEV/EROFS.
Fall back to direct writeFile when rename fails, with best-effort
cleanup of orphaned temp files.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
2587576514 fix(agent): improve specialist agent JSON resilience and finding schema
- Add complete finding JSON schema (all required fields) to both legacy
  and ReAct system prompts to prevent malformed responses
- Change JSON parse error handling from break (abandon review) to
  injecting a guidance message that prompts the model to return valid JSON
- Add global prompt injection support via withGlobalPrompt helper

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
f410373f7b fix(agent): fix rg args ordering in function reference search tool
The --type-add and --type options were placed after the path argument,
causing ripgrep to treat them as additional paths rather than flags.
Moved option flags before the -e pattern and path arguments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
ba2663552d fix(docker): add git, ca-certificates, and ripgrep to production image
Agent mode requires git for mirror cloning and rg for code search.
Both were missing from oven/bun:1-slim causing command failures (exit code -1).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
f3ba9de06f fix: remove isDev branches that caused production to use mock test data
Remove all isDev logic from review controller and config manager.
The isDev check treated missing NODE_ENV as development, causing
production to use a hardcoded fake commit SHA and skip real reviews.
Config validation now always fails fast on invalid configuration.
2026-03-24 12:30:13 +08:00
jeffusion
d84a0ed956 fix: make FEISHU_WEBHOOK_URL optional to prevent startup crash
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
2026-03-24 12:30:13 +08:00
jeffusion
dd147a24b4 chore(k8s): add Kubernetes deployment manifests 2026-03-24 12:30:13 +08:00
jeffusion
010582d702 chore(docker): add Qdrant service to docker-compose
- Add Qdrant vector database service with persistent storage
- Add health check and depends_on for service ordering
- Expose ports 6333 (HTTP) and 6334 (gRPC)
2026-03-03 19:02:31 +08:00
177 changed files with 16592 additions and 2809 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -1,53 +1,8 @@
# 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
# DATABASE_PATH=./data/assistant.db # 可选,默认为 ./data/assistant.db
ENCRYPTION_KEY= # 必填,运行 openssl rand -hex 32 生成
# 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:3000 进行配置。

View File

@@ -6,7 +6,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
@@ -15,11 +15,14 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: 1.3.10
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install frontend dependencies
run: cd frontend && bun install --frozen-lockfile
- name: Lint
run: bun run lint
@@ -28,3 +31,24 @@ jobs:
- name: Run tests
run: bun test
- name: Install Playwright Chromium
run: cd frontend && bunx playwright install --with-deps chromium
- name: Print visual stack versions
run: cd frontend && bun --version && bunx playwright --version && bunx playwright install --list
- name: Install deterministic CJK fonts for visual snapshots
run: sudo apt-get update && sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji fonts-liberation
- name: Run visual regression
run: bun run ui:visual
- name: Upload Playwright artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
frontend/playwright-report/
frontend/test-results/

14
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
bunx --bun @biomejs/biome check --staged --files-ignore-unknown=true --no-errors-on-unmatched

View File

@@ -1,3 +1,54 @@
## [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)

View File

@@ -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
# Codex CLI binary (statically linked musl build)
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
EXPOSE 3000
CMD ["bun", "run", "start"]

216
README.md
View File

@@ -2,31 +2,31 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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. Automatically reviews Pull Requests and commits using pluggable LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini), providing intelligent code quality analysis with both summary comments and line-level feedback.
**[中文文档](./docs/README.zh-CN.md)**
## Features
- 🤖 **AI Code Review** - Automatic review of PRs and commits using OpenAI models
- 🤖 **AI Code Review** - Automatic review of PRs and commits using pluggable LLM providers
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
- 🔄 **Dual Review Engines** - Legacy (simple) or Agent-based (multi-agent) review modes
- 🔄 **Task-Based Review Engines** - Agent staged review (skip/light/full) plus optional Codex CLI execution mode
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and configuration
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and LLM provider configuration
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
## Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Gitea Server │────▶│ Gitea Assistant │────▶│ OpenAI API
│ (Webhooks) │ │ (Hono + Bun) │ │
│ Gitea Server │────▶│ Gitea Assistant │────▶│ LLM Gateway
│ (Webhooks) │ │ (Hono + Bun) │ │ (Multi-Provider)
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ Admin Dashboard │
│ (React SPA) │
├─ OpenAI Compatible
┌──────────────────┐ ├─ OpenAI Responses API
│ Admin Dashboard │ ├─ Anthropic
│ (React SPA) │ └─ Google Gemini
└──────────────────┘
```
@@ -34,8 +34,8 @@ AI-powered code review assistant for Gitea. Automatically reviews Pull Requests
| 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 |
| `agent` | Task-based staged review (`skip` / `light` / `full`) with scoped specialist routing and optional reflection/debate escalation | Deep reviews with token-aware execution |
| `codex` | Codex CLI review execution with independent configuration | External Codex-driven review pipeline |
## Quick Start
@@ -43,7 +43,7 @@ AI-powered code review assistant for Gitea. Automatically reviews Pull Requests
- [Bun](https://bun.sh/) >= 1.2.5
- Gitea instance with API access
- OpenAI API key
- At least one LLM provider API key (OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible endpoint)
### Installation
@@ -51,29 +51,25 @@ AI-powered code review assistant for Gitea. Automatically reviews Pull Requests
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:
Create a `.env` file with only infrastructure-level settings:
```bash
# Gitea
GITEA_API_URL=https://your-gitea-instance.com/api/v1
GITEA_ACCESS_TOKEN=your_gitea_token
# Server port
PORT=3000
# OpenAI
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32)
ENCRYPTION_KEY=
# Security
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
# Admin Dashboard
ADMIN_PASSWORD=your_admin_password
# Optional: custom database path (default shown)
# DATABASE_PATH=./data/assistant.db
```
> **All other configuration** (Gitea connection, webhook secret, admin password, review engine, Feishu, memory settings, etc.) is managed through the **Admin Dashboard Web UI** at `http://your-server:3000`. On first boot, all settings are seeded with secure defaults automatically.
See [Configuration Reference](#configuration-reference) for all options.
### Running
@@ -88,7 +84,7 @@ bun run start # Production mode
**Option 1: Admin Dashboard (Recommended)**
1. Access `http://your-server:3000`
2. Log in with `ADMIN_PASSWORD`
2. Log in with the admin password (default: `password` — change it in the dashboard)
3. Click "Enable" on repositories to auto-configure webhooks
**Option 2: Manual Configuration**
@@ -96,77 +92,102 @@ bun run start # Production mode
In Gitea repository settings, add a webhook:
- **URL**: `http://your-server:3000/webhook/gitea`
- **Content Type**: `application/json`
- **Secret**: Same as `WEBHOOK_SECRET`
- **Secret**: Same as the Webhook Secret configured in the dashboard
- **Events**: "Pull Request" and "Status"
## Configuration Reference
### Core Settings
### Environment Variables (Minimal)
Only infrastructure-level settings that must be known before the database is initialized:
| 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 |
| `PORT` | Server port | `5174` |
| `DATABASE_PATH` | SQLite database file path | `./data/assistant.db` |
| `ENCRYPTION_KEY` | **Required.** AES-256-GCM encryption key for API key storage (64 hex chars). Generate with `openssl rand -hex 32` | |
### Custom Prompts
### Web UI Configuration (Admin Dashboard)
| Variable | Description |
|----------|-------------|
| `CUSTOM_SUMMARY_PROMPT` | Override the default summary review prompt |
| `CUSTOM_LINE_COMMENT_PROMPT` | Override the default line comment prompt |
All runtime configuration is managed through the **Admin Dashboard** at `http://your-server:PORT`. Changes take effect immediately without restart.
### Admin Dashboard
On first boot with an empty database, all settings are seeded with secure defaults:
- `JWT_SECRET` and `WEBHOOK_SECRET` are auto-generated (64-char hex via `crypto.randomBytes`)
- `ADMIN_PASSWORD` defaults to `password`**change this immediately**
| Variable | Description | Default |
|----------|-------------|---------|
| `ADMIN_PASSWORD` | Dashboard login password | `password` |
| `JWT_SECRET` | JWT signing secret | Auto-generated |
#### Gitea
### Feishu Integration
| Setting | Description |
|---------|-------------|
| Gitea API URL | Gitea API endpoint (e.g. `https://gitea.example.com/api/v1`) |
| Access Token | Token for code review (read + comment permissions) |
| Admin Token | Token for webhook management (optional) |
| Variable | Description |
|----------|-------------|
| `FEISHU_WEBHOOK_URL` | Feishu bot webhook URL |
| `FEISHU_WEBHOOK_SECRET` | Feishu webhook secret (optional) |
#### Security
### Agent Review Engine
| Setting | Description | Default |
|---------|-------------|---------|
| Webhook Secret | HMAC-SHA256 webhook signature secret | Auto-generated |
| Admin Password | Dashboard login password | `password` |
| JWT Secret | JWT signing secret | Auto-generated |
Enable with `REVIEW_ENGINE=agent` for advanced multi-agent reviews:
#### LLM Provider Configuration
| 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` |
LLM providers and models are configured exclusively through the **Admin Dashboard** Web UI:
### Memory & Learning (Experimental)
1. Navigate to **LLM 配置** (LLM Configuration)
2. Add your LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini)
3. Assign models to review roles (planner, specialist, judge, embedding)
| 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` |
> API keys are stored encrypted (AES-256-GCM) in the local SQLite database.
#### Feishu Integration
| Setting | Description |
|---------|-------------|
| Feishu Webhook URL | Feishu bot webhook URL |
| Feishu Webhook Secret | Feishu webhook secret (optional) |
#### Agent Review Engine
| Setting | Description | Default |
|---------|-------------|---------|
| Review Engine | Engine mode (`agent` or `codex`) | `agent` |
| Enable Triage | Enable planner triage for task routing | `true` |
| Small Max Files | Upper file-count bound for `small` review size | `3` |
| Small Max Changed Lines | Upper changed-lines bound for `small` review size | `80` |
| Medium Max Files | Upper file-count bound for `medium` review size | `10` |
| Medium Max Changed Lines | Upper changed-lines bound for `medium` review size | `400` |
| Token Budget Small | Token budget cap for `small` staged tasks | `12000` |
| Token Budget Medium | Token budget cap for `medium` staged tasks | `45000` |
| Token Budget Large | Token budget cap for `large` staged tasks | `120000` |
| Review Work Directory | Working directory for repo clones | `/tmp/gitea-assistant` |
| Max Parallel Runs | Max concurrent review tasks | `2` |
| Max Files per Run | Max files analyzed per review | `200` |
| Auto-publish Min Confidence | Min confidence score for auto-publish | `0.8` |
| Enable Human Gate | Require human approval before publishing | `true` |
Agent review execution model (current):
- `skip`: docs/assets/rename-only style changes can bypass specialist review.
- `light`: low-risk code changes run minimal scoped specialist checks.
- `full`: sensitive or larger changes run full specialist tasks, with optional reflection/debate escalation.
- Triage outputs task scopes (`paths`, `riskTags`, `mode`, `tokenBudget`) and orchestrator dispatches specialists by task scope instead of broad fan-out.
#### Memory & Learning (Experimental)
| Setting | 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
```bash
docker build -t gitea-assistant .
docker run -d -p 3000:3000 --env-file .env gitea-assistant
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
```
### Docker Compose
@@ -175,6 +196,51 @@ docker run -d -p 3000:3000 --env-file .env gitea-assistant
docker-compose up -d
```
### Kubernetes
Kubernetes manifests are located in the `k8s/` directory.
**1. Create the encryption secret**
```bash
# Generate a key and create the secret
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
# Save this key! You'll need it if you ever redeploy.
echo "Your 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
```
**4. Verify**
```bash
kubectl -n gitea-assistant get pods
kubectl -n gitea-assistant get svc
```
**5. Expose the Service (optional)**
By default, services use `ClusterIP`. To expose externally, use an Ingress or change the Service type:
```bash
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
```
## License
MIT License

208
bun.lock
View File

@@ -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,7 @@
"hono": "^4.11.9",
"lodash-es": "^4.17.21",
"openai": "^4.87.3",
"tokenlens": "^1.3.1",
"zod": "^3.25.1",
"zod-to-json-schema": "^3.25.1",
},
@@ -19,19 +23,25 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.6",
"@types/bun": "^1.3.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"semantic-release": "^24.2.9",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
@@ -52,8 +62,12 @@
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
"@google/genai": ["@google/genai@1.44.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
@@ -76,13 +90,35 @@
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@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 +144,28 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
"@tokenlens/helpers": ["@tokenlens/helpers@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0" } }, "sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ=="],
"@tokenlens/models": ["@tokenlens/models@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -142,14 +190,26 @@
"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=="],
"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 +238,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 +256,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 +268,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 +306,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 +324,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 +358,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 +380,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 +394,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 +432,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 +476,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 +502,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,7 +526,7 @@
"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=="],
@@ -432,7 +534,7 @@
"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 +552,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 +574,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=="],
@@ -484,6 +592,8 @@
"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=="],
"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=="],
@@ -500,9 +610,13 @@
"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=="],
"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=="],
@@ -542,10 +656,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=="],
@@ -574,23 +692,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 +744,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 +762,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 +784,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 +792,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 +816,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 +976,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 +1160,14 @@
"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=="],
"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 +1180,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 +1194,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 +1220,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 +1244,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 +1254,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
View File

@@ -0,0 +1,2 @@
[test]
root = "./src"

View File

@@ -46,22 +46,19 @@ 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
- REVIEW_WORKDIR=/tmp/e2e-review
- REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.5
- REVIEW_ENABLE_HUMAN_GATE=false
- REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
- REVIEW_COMMAND_TIMEOUT_MS=30000
ports:
- "3334:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
ports:
- "3334:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 5s
timeout: 3s
retries: 10

View File

@@ -10,13 +10,16 @@ services:
ports:
- "3000:3000"
volumes:
- ./config-overrides.json:/app/config-overrides.json
- assistant_data:/app/data
env_file:
- .env
depends_on:
qdrant:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
@@ -32,3 +35,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

View File

@@ -2,31 +2,31 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,使用 OpenAI 提供智能代码质量分析,支持总体评论和行级反馈。
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,支持多种 LLM 提供商OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini提供智能代码质量分析,支持总体评论和行级反馈。
**[English Documentation](../README.md)**
## 功能特点
- 🤖 **AI 代码审查** - 使用 OpenAI 模型自动审查 PR 和提交
- 🤖 **AI 代码审查** - 使用可插拔的 LLM 提供商自动审查 PR 和提交
- 📝 **行级评论** - 针对具体代码变更的精确反馈
- 🔄 **双引擎模式** - Legacy简单或 Agent多代理审查模式
- 🔄 **任务化审查引擎** - Agent 分级审查skip/light/full+ 可选 Codex CLI 审查模式
- 🔔 **飞书通知** - PR 事件通知集成
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和配置的 Web 界面
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和 LLM 提供商配置的 Web 界面
- 🔐 **安全验证** - HMAC-SHA256 签名验证
## 架构设计
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ OpenAI API
│ (Webhooks) │ │ (Hono + Bun) │ │
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ LLM 网关
│ (Webhooks) │ │ (Hono + Bun) │ │ (多提供商)
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ 管理后台 │
│ (React SPA) │
├─ OpenAI 兼容
┌──────────────────┐ ├─ OpenAI Responses API
│ 管理后台 │ ├─ Anthropic
│ (React SPA) │ └─ Google Gemini
└──────────────────┘
```
@@ -34,8 +34,8 @@
| 引擎 | 描述 | 适用场景 |
|------|------|----------|
| `legacy` | 单次 AI 审查,生成总结和行级评论 | 简单、快速的审查 |
| `agent` | 多代理编排,支持专家、反思和辩论 | 深度、全面的分析 |
| `agent` | 任务化分级审查(`skip` / `light` / `full`),按路径范围派发 specialist并按需升级到反思/辩论 | 在控制 token 成本的前提下做深度审查 |
| `codex` | 通过 Codex CLI 执行审查,独立配置 | 对接外部 Codex 审查流程 |
## 快速开始
@@ -43,7 +43,7 @@
- [Bun](https://bun.sh/) >= 1.2.5
- 可访问的 Gitea 实例
- OpenAI API 密钥
- 至少一个 LLM 提供商的 API 密钥OpenAI、Anthropic、Google Gemini 或任何 OpenAI 兼容端点)
### 安装步骤
@@ -51,29 +51,25 @@
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
cp .env.example .env
```
### 配置说明
编辑 `.env` 文件:
创建 `.env` 文件,仅填写基础设施级别的配置
```bash
# Gitea
GITEA_API_URL=https://your-gitea-instance.com/api/v1
GITEA_ACCESS_TOKEN=your_gitea_token
# 服务端口
PORT=3000
# OpenAI
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
# 必填API Key 加密存储密钥(运行 openssl rand -hex 32 生成)
ENCRYPTION_KEY=
# 安全
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
# 管理后台
ADMIN_PASSWORD=your_admin_password
# 可选:自定义数据库路径(以下为默认值)
# DATABASE_PATH=./data/assistant.db
```
> **所有其他配置**Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:3000` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
完整配置项请参阅 [配置参考](#配置参考)。
### 启动服务
@@ -88,7 +84,7 @@ bun run start # 生产模式
**方式一:管理后台(推荐)**
1. 在浏览器中访问 `http://your-server:3000`
2. 使用 `ADMIN_PASSWORD` 登录
2. 使用管理员密码登录(默认:`password`,请在后台及时修改)
3. 点击仓库对应的「启用」按钮自动配置 Webhook
**方式二:手动配置**
@@ -96,77 +92,102 @@ bun run start # 生产模式
在 Gitea 仓库设置中添加 Webhook
- **URL**: `http://your-server:3000/webhook/gitea`
- **内容类型**: `application/json`
- **密钥**: 与 `WEBHOOK_SECRET` 相同
- **密钥**: 与管理后台中配置的 Webhook 密钥相同
- **触发事件**: 「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 签名验证密钥 | 必填 |
| `PORT` | 服务端口 | `5174` |
| `DATABASE_PATH` | SQLite 数据库文件路径 | `./data/assistant.db` |
| `ENCRYPTION_KEY` | **必填。** AES-256-GCM 加密密钥,用于加密存储 API Key64 位十六进制字符串)。运行 `openssl rand -hex 32` 生成 | |
### 自定义提示词
### Web 界面配置(管理后台)
| 变量 | 描述 |
|------|------|
| `CUSTOM_SUMMARY_PROMPT` | 自定义总结审查提示词 |
| `CUSTOM_LINE_COMMENT_PROMPT` | 自定义行级评论提示词 |
所有运行时配置均通过 **管理后台** `http://your-server:PORT` 进行管理,修改后立即生效,无需重启。
### 管理后台
首次以空数据库启动时,所有设置会自动以安全默认值初始化:
- `JWT_SECRET``WEBHOOK_SECRET` 自动生成(通过 `crypto.randomBytes` 生成 64 位十六进制字符串)
- `ADMIN_PASSWORD` 默认为 `password`**请立即修改**
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `ADMIN_PASSWORD` | 后台登录密码 | `password` |
| `JWT_SECRET` | JWT 签名密钥 | 自动生成 |
#### Gitea
### 飞书集成
| 配置项 | 描述 |
|--------|------|
| Gitea API 地址 | Gitea API 端点(如 `https://gitea.example.com/api/v1` |
| 访问令牌 | 代码审查令牌(需读取和评论权限) |
| 管理员令牌 | Webhook 管理令牌(可选) |
| 变量 | 描述 |
|------|------|
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook 地址 |
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 密钥(可选) |
#### 安全
### Agent 审查引擎
| 配置项 | 描述 | 默认值 |
|--------|------|--------|
| Webhook 密钥 | HMAC-SHA256 Webhook 签名密钥 | 自动生成 |
| 管理员密码 | 后台登录密码 | `password` |
| JWT 密钥 | JWT 签名密钥 | 自动生成 |
设置 `REVIEW_ENGINE=agent` 启用多代理审查:
#### LLM 提供商配置
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `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` |
LLM 提供商和模型通过**管理后台** Web 界面进行配置:
### 记忆与学习(实验性)
1. 导航到 **LLM 配置** 页面
2. 添加 LLM 提供商OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini
3. 为审查角色分配模型planner、specialist、judge、embedding
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `QDRANT_URL` | Qdrant 向量数据库地址 | - |
| `ENABLE_MEMORY` | 启用记忆系统 | `false` |
| `ENABLE_REFLECTION` | 启用自我批评 | `false` |
| `ENABLE_DEBATE` | 启用多代理辩论 | `false` |
> API 密钥使用 AES-256-GCM 加密存储在本地 SQLite 数据库中。
#### 飞书集成
| 配置项 | 描述 |
|--------|------|
| 飞书 Webhook 地址 | 飞书机器人 Webhook URL |
| 飞书 Webhook 密钥 | 飞书 Webhook 密钥(可选) |
#### Agent 审查引擎
| 配置项 | 描述 | 默认值 |
|--------|------|--------|
| 审查引擎 | 引擎模式(`agent``codex` | `agent` |
| 启用分流Enable Triage | 启用 planner 分流并输出任务化审查计划 | `true` |
| Small 文件上限 | 判定 `small` 规模审查的文件数上限 | `3` |
| Small 变更行上限 | 判定 `small` 规模审查的变更行数上限 | `80` |
| Medium 文件上限 | 判定 `medium` 规模审查的文件数上限 | `10` |
| Medium 变更行上限 | 判定 `medium` 规模审查的变更行数上限 | `400` |
| Small Token 预算 | `small` 任务的 token 预算上限 | `12000` |
| Medium Token 预算 | `medium` 任务的 token 预算上限 | `45000` |
| Large Token 预算 | `large` 任务的 token 预算上限 | `120000` |
| 工作目录 | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
| 最大并发数 | 最大并发审查任务数 | `2` |
| 最大文件数 | 单次审查最大文件数 | `200` |
| 自动发布置信度 | 自动发布最小置信度 | `0.8` |
| 启用人工审批 | 发布前要求人工确认 | `true` |
当前 Agent 审查执行模型:
- `skip`:文档/资源/纯重命名等低风险改动可直接跳过 specialist。
- `light`:低风险代码改动执行最小化、按路径范围限定的 specialist 审查。
- `full`:敏感路径或中大型改动执行完整任务审查,并可按配置升级到 reflection/debate。
- Triage 输出任务(`paths``riskTags``mode``tokenBudget`Orchestrator 按任务范围派发,不再默认全量扇出。
#### 记忆与学习(实验性)
| 配置项 | 描述 | 默认值 |
|--------|------|--------|
| Qdrant 地址 | Qdrant 向量数据库地址 | - |
| 启用记忆 | 启用记忆系统 | `false` |
| 启用反思 | 启用自我批评 | `false` |
| 启用辩论 | 启用多代理辩论 | `false` |
## 部署指南
### Docker
```bash
docker build -t gitea-assistant .
docker run -d -p 3000:3000 --env-file .env gitea-assistant
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
```
### Docker Compose
@@ -175,6 +196,51 @@ docker run -d -p 3000:3000 --env-file .env gitea-assistant
docker-compose up -d
```
### Kubernetes
Kubernetes 部署清单位于 `k8s/` 目录。
**1. 创建加密密钥**
```bash
# 生成密钥并创建 Secret
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
# 请保存此密钥!重新部署时需要使用。
echo "你的 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
```
**4. 验证**
```bash
kubectl -n gitea-assistant get pods
kubectl -n gitea-assistant get svc
```
**5. 暴露服务(可选)**
默认使用 `ClusterIP` 类型。如需外部访问,可使用 Ingress 或修改 Service 类型:
```bash
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
```
## 许可证
MIT 许可证

View 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 groupLLM 配置全部走 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, -- 可选自定义 endpointopenai_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 modevs 需要 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_responsesResponses 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 anthropicMessages API
```typescript
// 请求转换:
// - system message 提取为 system 顶层参数
// - 非 system messages → messagesrole 直接映射)
// - 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 geminigenerateContent 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_KEYhex 编码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 ProvidersTab 或独立 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 Keytrade-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=3000
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 引擎配置
# - 记忆系统配置
```

View 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 themesMIT
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
3. `nord`
- 来源NordMIT
- 参考:<https://github.com/nordtheme/nord>
4. `tokyo-night`
- 来源Tokyo NightApache-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 视觉回归。

View File

@@ -115,7 +115,45 @@ git commit -m "feat: add user handler"
git push origin feature/add-user-handler 2>/dev/null
popd > /dev/null
echo "=== [5/6] 配置 Webhook ==="
echo "=== [5/7] 配置 Assistant 设置 ==="
ADMIN_DEFAULT_PASS="password"
# Wait for assistant to be healthy
for i in $(seq 1 20); do
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
echo " Assistant 已就绪"
break
fi
echo " 等待 Assistant... ($i/20)"
sleep 3
done
# Login to get JWT
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
-H "Content-Type: application/json" \
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
if [ -z "${ADMIN_JWT}" ]; then
echo " WARNING: 无法获取管理员 JWT跳过 assistant 配置"
else
echo " JWT 获取成功,配置 assistant 设置..."
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-d "{
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
\"REVIEW_ENGINE\": \"agent\",
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
\"REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE\": \"0.5\",
\"REVIEW_ENABLE_HUMAN_GATE\": \"false\",
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
\"REVIEW_COMMAND_TIMEOUT_MS\": \"30000\"
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
fi
echo "=== [6/7] 配置 Webhook ==="
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
@@ -129,8 +167,7 @@ curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
\"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" \

4
frontend/.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright
playwright-report/
test-results/

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -7,7 +7,11 @@
"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-label": "^2.1.7",
@@ -30,7 +34,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 +48,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"
}
}

View 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,
},
});

View 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

View 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();

View File

@@ -4,7 +4,10 @@ import { LoginPage } from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import { RepositoryManager } from './components/RepositoryManager';
import { ConfigManager } from './components/ConfigManager';
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 +18,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 +34,9 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function App() {
function AppContent() {
const { resolvedTheme } = useTheme();
return (
<BrowserRouter>
<Routes>
@@ -46,12 +51,21 @@ function App() {
<Route index element={<Navigate to="/repos" replace />} />
<Route path="repos" element={<RepositoryManager />} />
<Route path="config" element={<ConfigManager />} />
<Route path="review-config" element={<ReviewConfigPage />} />
<Route path="*" element={<Navigate to="/repos" replace />} />
</Route>
</Routes>
<Toaster theme="dark" />
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />
</BrowserRouter>
);
}
function App() {
return (
<ColorPaletteProvider>
<AppContent />
</ColorPaletteProvider>
);
}
export default App;

View File

@@ -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>

View File

@@ -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,8 @@ interface ConfigGroupCardProps {
onFieldChange: (envKey: string, value: any) => void;
onReset: (keys: string[]) => void;
isResetting: boolean;
/** 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 +35,14 @@ export function ConfigGroupCard({
onFieldChange,
onReset,
isResetting,
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,20 +51,20 @@ 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>
@@ -71,22 +75,26 @@ export function ConfigGroupCard({
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"
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>
)}
</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>
);

View File

@@ -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', 'feishu', '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}

View File

@@ -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>

View File

@@ -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-[40%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
<TableHead className="w-[30%] 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-6 w-20 bg-muted/70 rounded-full" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-8 w-16 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>

View File

@@ -8,7 +8,7 @@ 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",
@@ -17,8 +17,8 @@ export const columns: ColumnDef<Repository>[] = [
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>}
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-success/10 text-success border-success/30' : 'bg-transparent text-muted-foreground border-border theme-border-soft'}`}>
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse theme-glow-success"></span>}
{isActive ? '已启用' : '未启用'}
</div>
)
@@ -26,7 +26,7 @@ export const columns: ColumnDef<Repository>[] = [
},
{
id: "actions",
header: () => <div className="text-right text-zinc-400"></div>,
header: () => <div className="text-right text-muted-foreground"></div>,
cell: ({ row }) => {
const repo = row.original
return (

View File

@@ -0,0 +1,381 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchConfig, updateConfig, resetConfig } from '@/services/configService';
import type { ConfigResponse, ConfigGroupDto, ConfigFieldDto } from '@/services/configService';
import { ConfigGroupCard } from './ConfigGroupCard';
import { ModelCombobox } from './llm/ModelCombobox';
import { ProviderList } from './llm/ProviderList';
import { RoleAssignment } from './llm/RoleAssignment';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Save, AlertCircle, RotateCcw, Layers } from 'lucide-react';
import { toast } from 'sonner';
// ---------------------------------------------------------------------------
// Engine-specific field visibility
// ---------------------------------------------------------------------------
type EngineMode = 'agent' | 'codex';
/** The engine selector field — always visible at the top. */
const ENGINE_FIELD = 'REVIEW_ENGINE';
const AGENT_SHARED_FIELDS = new Set([
'GLOBAL_PROMPT',
'REVIEW_WORKDIR',
'REVIEW_MAX_PARALLEL_RUNS',
'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS',
]);
/** Fields specific to agent mode only. */
const AGENT_ONLY_FIELDS = new Set([
'REVIEW_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 'agent':
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
case 'codex':
return CODEX_FIELDS.has(f.envKey);
default:
return false;
}
});
}
// ---------------------------------------------------------------------------
// Engine selector badges
// ---------------------------------------------------------------------------
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ReviewConfigPage() {
const queryClient = useQueryClient();
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
const [hasChanges, setHasChanges] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
// Derived: current engine mode
const engine: EngineMode = useMemo(() => {
const val = localConfig[ENGINE_FIELD];
if (val === 'agent' || val === 'codex') return val;
return 'agent';
}, [localConfig]);
// Derived: review group and memory group from fetched data
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]);
// Initialize local config from ALL groups (so save works for review + memory fields)
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
data.groups
.filter((g) => g.key === 'review' || g.key === 'memory')
.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 groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
const allOverrideKeys = groups
.flatMap((g) => g.fields)
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
if (allOverrideKeys.length === 0) return;
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
resetMutation.mutate(allOverrideKeys);
}
};
// Derived: visible fields for the current engine
const visibleReviewFields = useMemo(
() => (reviewGroup ? getVisibleFields(engine, reviewGroup.fields) : []),
[engine, reviewGroup]
);
const hasOverrides = useMemo(() => {
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
}, [reviewGroup, memoryGroup]);
// -- Render states --
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-48 bg-muted/60" />
<Skeleton className="h-10 w-24 bg-muted/60" />
</div>
<Skeleton className="h-[200px] w-full rounded-xl bg-muted/60 border border-border/60" />
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
</div>
);
}
if (isError) {
return (
<div className="theme-error-panel flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
);
}
// Build a synthetic group for the visible review fields
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
? {
...reviewGroup,
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
description:
engine === 'codex'
? 'Codex CLI 审查引擎配置'
: '多代理编排审查引擎配置',
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}
/>
)}
{/* Memory group — agent mode only */}
{engine === 'agent' && memoryGroup && (
<ConfigGroupCard
group={memoryGroup}
localConfig={localConfig}
onFieldChange={handleFieldChange}
onReset={handleResetGroup}
isResetting={resetMutation.isPending}
/>
)}
{engine !== 'codex' && (
<>
<ProviderList />
<RoleAssignment />
</>
)}
</div>
</div>
);
}

View File

@@ -37,8 +37,8 @@ export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleB
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"
? "border-danger/50 bg-transparent text-danger hover:bg-danger/10 hover:text-danger transition-colors"
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 tech-glow"
}
onClick={() => mutation.mutate()}
disabled={mutation.isPending}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1,214 @@
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 { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { Save, ShieldCheck } from 'lucide-react';
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
import { ModelCombobox } from './ModelCombobox';
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
};
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
interface RoleState {
providerId: string | null;
model: string;
}
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,
});
useEffect(() => {
if (roles.length > 0) {
const initial: Record<string, RoleState> = {};
roles.forEach(role => {
initial[role.role] = {
providerId: role.providerId,
model: role.model || '',
};
});
// Fill missing roles
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">
</CardTitle>
<CardDescription className="text-muted-foreground">
AI
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="theme-card-content">
{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;
return (
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
<div className="w-full md:w-1/3 space-y-1.5">
<Label className="text-base font-semibold text-foreground">
{ROLE_LABELS[role]?.label || role}
</Label>
<p className="text-sm text-muted-foreground leading-relaxed">
{ROLE_LABELS[role]?.desc}
</p>
</div>
<div className="w-full md:w-2/3 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>
)}
</CardContent>
</Card>
);
}

View 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>
);
}

View 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();
});
});

View 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();
});
});

View 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();
});
});

View File

@@ -0,0 +1,98 @@
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 { 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(),
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 role cards and supports provider/model editing', 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(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('角色分配')).toBeInTheDocument();
expect(await screen.findByText('规划器 Planner')).toBeInTheDocument();
// Radix Select renders placeholder in a span with pointer-events: none.
// Click the trigger button (parent) instead of the placeholder text.
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');
});
});

View File

@@ -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);
});
});

View File

@@ -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>
)
}

View 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;
};

View File

@@ -6,48 +6,313 @@
@layer base {
:root {
color-scheme: light;
--background: 240 10% 98%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 180 100% 35%;
--primary: 224 76% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 180 100% 35%;
--accent-foreground: 0 0% 100%;
--accent: 220 18% 94%;
--accent-foreground: 240 10% 8%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 180 100% 35%;
--ring: 224 76% 52%;
--success: 160 84% 39%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 24 10% 10%;
--danger: 0 72% 51%;
--danger-foreground: 0 0% 100%;
--info: 214 89% 55%;
--info-foreground: 0 0% 100%;
--surface-muted: 240 8% 94%;
--surface-elevated: 0 0% 100%;
--surface-overlay: 240 16% 14%;
--text-subtle: 240 4% 43%;
--text-soft: 240 4% 58%;
--border-soft: 240 6% 84%;
--radius: 0.5rem;
}
.dark {
color-scheme: dark;
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--card: 240 10% 6%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 6%;
--popover-foreground: 0 0% 98%;
--primary: 175 90% 45%;
--primary-foreground: 240 10% 4%;
--primary: 224 88% 68%;
--primary-foreground: 224 40% 12%;
--secondary: 240 5% 15%;
--secondary-foreground: 0 0% 98%;
--muted: 240 5% 15%;
--muted-foreground: 240 5% 65%;
--accent: 175 90% 45%;
--accent-foreground: 240 10% 4%;
--accent: 240 6% 16%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 5% 15%;
--input: 240 5% 15%;
--ring: 175 90% 45%;
--ring: 224 88% 68%;
--success: 160 80% 46%;
--success-foreground: 0 0% 100%;
--warning: 39 92% 58%;
--warning-foreground: 24 10% 10%;
--danger: 0 80% 63%;
--danger-foreground: 0 0% 100%;
--info: 214 88% 65%;
--info-foreground: 0 0% 100%;
--surface-muted: 240 6% 11%;
--surface-elevated: 240 8% 14%;
--surface-overlay: 240 5% 6%;
--text-subtle: 240 5% 72%;
--text-soft: 240 5% 60%;
--border-soft: 240 5% 21%;
}
:root[data-palette='zinc'] {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 47.9 95.8% 53.1%;
--warning-foreground: 26 83.3% 14.1%;
--danger: 0 84.2% 60.2%;
--danger-foreground: 0 0% 98%;
--info: 221.2 83.2% 53.3%;
--info-foreground: 210 40% 98%;
--surface-muted: 240 4.8% 95.9%;
--surface-elevated: 0 0% 100%;
--surface-overlay: 240 10% 3.9%;
--text-subtle: 240 3.8% 46.1%;
--text-soft: 240 5% 64.9%;
--border-soft: 240 5.9% 84%;
}
.dark[data-palette='zinc'] {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--warning: 47.9 95.8% 53.1%;
--warning-foreground: 26 83.3% 14.1%;
--danger: 0 72% 51%;
--danger-foreground: 0 0% 98%;
--info: 217.2 91.2% 59.8%;
--info-foreground: 222.2 47.4% 11.2%;
--surface-muted: 240 3.7% 15.9%;
--surface-elevated: 240 3.7% 18%;
--surface-overlay: 240 23% 9%;
--text-subtle: 240 5% 64.9%;
--text-soft: 240 5% 56%;
--border-soft: 240 3.7% 24%;
}
:root[data-palette='nord'] {
--background: 220 13% 91%;
--foreground: 220 13% 17%;
--card: 220 13% 88%;
--card-foreground: 220 13% 17%;
--popover: 220 13% 91%;
--popover-foreground: 220 13% 17%;
--primary: 204 48% 68%;
--primary-foreground: 220 13% 17%;
--secondary: 213 31% 48%;
--secondary-foreground: 220 13% 91%;
--muted: 220 13% 83%;
--muted-foreground: 220 13% 35%;
--accent: 192 34% 64%;
--accent-foreground: 220 13% 17%;
--destructive: 353 50% 57%;
--destructive-foreground: 220 13% 91%;
--border: 220 13% 75%;
--input: 220 13% 75%;
--ring: 204 48% 68%;
--success: 136 44% 64%;
--success-foreground: 220 13% 17%;
--warning: 43 74% 73%;
--warning-foreground: 220 13% 17%;
--danger: 353 50% 57%;
--danger-foreground: 220 13% 91%;
--info: 222 63% 70%;
--info-foreground: 220 13% 17%;
--surface-muted: 220 13% 83%;
--surface-elevated: 220 13% 88%;
--surface-overlay: 220 13% 17%;
--text-subtle: 220 13% 35%;
--text-soft: 220 13% 45%;
--border-soft: 220 13% 80%;
}
.dark[data-palette='nord'] {
--background: 220 13% 12%;
--foreground: 220 13% 91%;
--card: 220 13% 16%;
--card-foreground: 220 13% 91%;
--popover: 220 13% 16%;
--popover-foreground: 220 13% 91%;
--primary: 204 48% 68%;
--primary-foreground: 220 13% 12%;
--secondary: 213 31% 48%;
--secondary-foreground: 220 13% 91%;
--muted: 220 13% 24%;
--muted-foreground: 220 13% 70%;
--accent: 192 34% 64%;
--accent-foreground: 220 13% 91%;
--destructive: 353 50% 57%;
--destructive-foreground: 220 13% 91%;
--border: 220 13% 28%;
--input: 220 13% 28%;
--ring: 204 48% 68%;
--success: 136 44% 64%;
--success-foreground: 220 13% 12%;
--warning: 43 74% 73%;
--warning-foreground: 220 13% 12%;
--danger: 353 50% 57%;
--danger-foreground: 220 13% 91%;
--info: 222 63% 70%;
--info-foreground: 220 13% 12%;
--surface-muted: 220 13% 24%;
--surface-elevated: 220 13% 16%;
--surface-overlay: 220 13% 12%;
--text-subtle: 220 13% 70%;
--text-soft: 220 13% 60%;
--border-soft: 220 13% 20%;
}
:root[data-palette='tokyo-night'] {
--background: 230 15% 95%;
--foreground: 230 20% 15%;
--card: 230 15% 92%;
--card-foreground: 230 20% 15%;
--popover: 230 15% 92%;
--popover-foreground: 230 20% 15%;
--primary: 219 89% 72%;
--primary-foreground: 230 20% 15%;
--secondary: 268 89% 78%;
--secondary-foreground: 230 20% 15%;
--muted: 230 10% 85%;
--muted-foreground: 230 10% 45%;
--accent: 195 100% 74%;
--accent-foreground: 230 20% 15%;
--destructive: 343 91% 73%;
--destructive-foreground: 230 15% 98%;
--border: 230 10% 80%;
--input: 230 10% 80%;
--ring: 219 89% 72%;
--success: 80 78% 62%;
--success-foreground: 230 20% 15%;
--warning: 35 85% 66%;
--warning-foreground: 230 20% 15%;
--danger: 343 91% 73%;
--danger-foreground: 230 15% 98%;
--info: 195 100% 74%;
--info-foreground: 230 20% 15%;
--surface-muted: 230 10% 85%;
--surface-elevated: 230 15% 92%;
--surface-overlay: 230 20% 15%;
--text-subtle: 230 10% 45%;
--text-soft: 230 10% 56%;
--border-soft: 230 10% 85%;
}
.dark[data-palette='tokyo-night'] {
--background: 232 23% 10%;
--foreground: 219 28% 88%;
--card: 232 20% 14%;
--card-foreground: 219 28% 88%;
--popover: 232 20% 14%;
--popover-foreground: 219 28% 88%;
--primary: 219 89% 72%;
--primary-foreground: 232 23% 10%;
--secondary: 268 89% 78%;
--secondary-foreground: 232 23% 10%;
--muted: 232 20% 20%;
--muted-foreground: 219 28% 60%;
--accent: 195 100% 74%;
--accent-foreground: 232 23% 10%;
--destructive: 343 91% 73%;
--destructive-foreground: 219 28% 88%;
--border: 232 20% 25%;
--input: 232 20% 25%;
--ring: 219 89% 72%;
--success: 80 78% 62%;
--success-foreground: 232 23% 10%;
--warning: 35 85% 66%;
--warning-foreground: 232 23% 10%;
--danger: 343 91% 73%;
--danger-foreground: 219 28% 88%;
--info: 195 100% 74%;
--info-foreground: 232 23% 10%;
--surface-muted: 232 20% 20%;
--surface-elevated: 232 20% 14%;
--surface-overlay: 232 23% 10%;
--text-subtle: 219 28% 60%;
--text-soft: 219 28% 52%;
--border-soft: 232 20% 18%;
}
}
@@ -72,16 +337,155 @@
}
.bg-grid-pattern {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-image: linear-gradient(to right, hsl(var(--foreground) / 0.05) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.05) 1px, transparent 1px);
}
.glass-panel {
@apply bg-zinc-950/50 backdrop-blur-xl border border-white/10 shadow-2xl;
@apply bg-card/80 backdrop-blur-xl border border-border shadow-2xl;
}
.theme-surface-muted {
background: hsl(var(--surface-muted));
}
.theme-surface-elevated {
background: hsl(var(--surface-elevated));
}
.theme-surface-overlay {
background: hsl(var(--surface-overlay) / 0.62);
}
.theme-border-soft {
border-color: hsl(var(--border-soft));
}
.theme-text-subtle {
color: hsl(var(--text-subtle));
}
.theme-text-soft {
color: hsl(var(--text-soft));
}
.theme-glow-primary {
box-shadow: 0 0 18px -6px hsl(var(--primary) / 0.45);
}
.theme-glow-success {
box-shadow: 0 0 10px 0 hsl(var(--success) / 0.55);
}
.theme-glow-warning {
box-shadow: 0 0 10px 0 hsl(var(--warning) / 0.55);
}
.theme-glow-danger {
box-shadow: 0 0 10px 0 hsl(var(--danger) / 0.55);
}
.theme-shell-gradient {
background-image:
radial-gradient(circle at 12% 8%, hsl(var(--primary) / 0.1), transparent 34%),
radial-gradient(circle at 92% 82%, hsl(var(--accent) / 0.12), transparent 38%),
linear-gradient(180deg, hsl(var(--background) / 0.98), hsl(var(--background)) 40%);
}
.theme-control-pill {
@apply inline-flex items-center gap-2 rounded-full border border-border/70 bg-muted/55 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur;
}
.theme-input-surface {
@apply bg-muted/45 border-border/70 text-foreground placeholder:text-muted-foreground/70 focus-visible:border-primary/40 focus-visible:ring-primary/20;
}
.theme-interactive-elevate {
transition: transform 180ms ease, box-shadow 220ms ease, border-color 220ms ease;
}
.theme-interactive-elevate:hover {
transform: translateY(-1px);
box-shadow: 0 16px 35px -30px hsl(var(--foreground) / 0.8);
border-color: hsl(var(--border-soft));
}
.theme-sticky-bar {
@apply backdrop-blur-2xl border-b border-border/70;
background: hsl(var(--background) / 0.88);
box-shadow: 0 12px 30px -26px hsl(var(--foreground) / 0.55);
}
.theme-sidebar-shell {
@apply border-r border-border/70 backdrop-blur-xl;
background:
linear-gradient(180deg, hsl(var(--surface-elevated) / 0.9) 0%, hsl(var(--background) / 0.86) 100%);
box-shadow: inset -1px 0 0 hsl(var(--border-soft) / 0.35);
}
.theme-sidebar-header {
@apply border-b border-border/60;
background: hsl(var(--surface-elevated) / 0.9);
}
.theme-sidebar-footer {
@apply border-t border-border/55;
background: hsl(var(--background) / 0.78);
}
.theme-page-frame {
@apply min-h-screen pb-12;
}
.theme-page-actions {
@apply flex flex-wrap items-center justify-end gap-3 max-w-6xl mx-auto;
}
.theme-page-content {
@apply max-w-6xl mx-auto mt-7 space-y-8 px-4 md:px-6 lg:px-8;
}
.theme-card-shell {
@apply relative overflow-hidden rounded-2xl border border-border/70 backdrop-blur-xl;
background: hsl(var(--card) / 0.88);
box-shadow: 0 20px 50px -42px hsl(var(--foreground) / 0.85);
}
.theme-card-header {
@apply border-b border-border/60 bg-muted/35 px-6 py-5;
}
.theme-card-content {
@apply p-6 bg-background/35;
}
.theme-error-panel {
@apply glass-panel p-4 rounded-lg text-danger border border-danger/20 bg-danger/10;
}
.theme-dialog-panel {
@apply glass-panel w-full max-w-md bg-card border border-border rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh];
}
.theme-dialog-header {
@apply px-6 py-4 border-b border-border;
}
.theme-dialog-body {
@apply px-6 py-5 overflow-y-auto flex-1;
}
.theme-dialog-footer {
@apply px-6 py-4 border-t border-border bg-muted/40;
}
.theme-spin-reverse-slow {
animation: spin 1.5s linear infinite reverse;
}
.tech-glow {
box-shadow: 0 0 20px -5px hsl(var(--primary) / 0.5);
box-shadow: 0 0 18px -8px hsl(var(--foreground) / 0.2);
}
.tech-glow:hover {
box-shadow: 0 0 30px -5px hsl(var(--primary) / 0.7);
box-shadow: 0 0 24px -8px hsl(var(--foreground) / 0.3);
}
}

View File

@@ -18,4 +18,20 @@ api.interceptors.request.use(
}
);
// 添加响应拦截器,处理 401 未授权自动跳转登录页
api.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
localStorage.removeItem('authToken');
// 避免在登录接口本身触发跳转
const isLoginRequest = error.config?.url?.includes('/login');
if (!isLoginRequest) {
window.location.href = '/';
}
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import axios from 'axios'
import { ThemeProvider } from 'next-themes'
const queryClient = new QueryClient({
defaultOptions: {
@@ -21,13 +22,13 @@ const queryClient = new QueryClient({
},
});
// Force dark mode as requested
document.documentElement.classList.add('dark');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>,
)

View File

@@ -1,15 +1,21 @@
import { useState, useEffect } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
const navItems = [
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
{ path: '/config', label: '配置管理', icon: Sliders },
{ path: '/config', label: '系统配置', icon: Sliders },
{ path: '/review-config', label: '审查配置', icon: FileSearch },
] as const;
export default function DashboardPage() {
const location = useLocation();
const { setTheme, resolvedTheme } = useTheme();
const { palette, setPalette } = useColorPalette();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -25,30 +31,31 @@ export default function DashboardPage() {
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
const isConfigPage = location.pathname.startsWith('/config');
const isReviewConfigPage = location.pathname.startsWith('/review-config');
return (
<div className="flex h-screen w-full overflow-hidden bg-background">
<div className="theme-shell-gradient flex h-screen w-full overflow-hidden bg-background">
{/* Mobile Overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in"
className="fixed inset-0 z-40 theme-surface-overlay backdrop-blur-md lg:hidden animate-in fade-in"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-zinc-950 transition-all duration-300 ease-in-out lg:relative ${
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
className={`theme-sidebar-shell fixed inset-y-0 left-0 z-50 flex flex-col transition-all duration-300 ease-in-out ${
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
>
<div className="flex h-16 items-center justify-between px-4 border-b border-border/50 bg-zinc-950">
<div className="theme-sidebar-header flex h-16 items-center justify-between px-4">
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isSidebarCollapsed ? 'w-10 justify-center -ml-1' : 'w-full'}`}>
<div className="flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-[0_0_15px_rgba(20,184,166,0.15)] ring-1 ring-primary/10">
<div className="theme-interactive-elevate flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 theme-glow-primary ring-1 ring-primary/10">
<Bot className="h-5 w-5" />
</div>
{!isSidebarCollapsed && (
<span className="truncate font-bold tracking-tight text-zinc-100 whitespace-nowrap">
<span className="truncate font-bold tracking-tight text-foreground whitespace-nowrap">
Gitea AI Assistant
</span>
)}
@@ -56,7 +63,7 @@ export default function DashboardPage() {
<Button
variant="ghost"
size="icon"
className="lg:hidden shrink-0 h-8 w-8 text-zinc-400 hover:text-zinc-100"
className="lg:hidden shrink-0 h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setIsMobileMenuOpen(false)}
>
<X className="h-4 w-4" />
@@ -71,20 +78,20 @@ export default function DashboardPage() {
key={item.path}
to={item.path}
className={({ isActive }) =>
`group relative flex w-full items-center rounded-xl p-2.5 transition-all duration-200 ${
`group relative flex w-full items-center rounded-xl border p-2.5 transition-all duration-200 ${
isActive
? 'bg-primary/10 text-primary'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100'
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
}
? 'border-primary/[0.14] bg-primary/[0.08] text-primary shadow-sm'
: 'border-transparent text-muted-foreground hover:bg-accent/50 hover:border-border/60 hover:text-foreground'
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
}
title={isSidebarCollapsed ? item.label : undefined}
>
{({ isActive }) => (
<>
{isActive && (
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary shadow-[0_0_10px_rgba(20,184,166,0.5)]"></div>
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary theme-glow-primary"></div>
)}
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-zinc-500 group-hover:text-zinc-300'}`} />
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-muted-foreground group-hover:text-foreground'}`} />
{!isSidebarCollapsed && (
<span className="font-medium tracking-wide text-sm">{item.label}</span>
)}
@@ -95,10 +102,10 @@ export default function DashboardPage() {
})}
</nav>
<div className="border-t border-border/50 p-3 bg-zinc-950">
<div className="theme-sidebar-footer p-3">
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
className={`hidden lg:flex w-full items-center rounded-xl p-2.5 text-zinc-500 transition-colors hover:bg-zinc-900 hover:text-zinc-300 ${
className={`hidden lg:flex w-full items-center rounded-xl border border-transparent p-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:border-border/60 hover:text-foreground ${
isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'
}`}
>
@@ -115,37 +122,76 @@ export default function DashboardPage() {
</aside>
{/* Main Content */}
<div className="flex flex-1 flex-col overflow-hidden relative">
<div
className={`relative flex flex-1 flex-col overflow-hidden transition-[margin] duration-300 ease-in-out ${
isSidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64'
}`}
>
{/* Top Header */}
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border/50 bg-background/80 px-4 backdrop-blur-md z-10">
<header className="theme-sticky-bar flex h-16 shrink-0 items-center justify-between px-4 z-10">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="lg:hidden text-zinc-400 hover:text-zinc-100 h-9 w-9 -ml-2"
className="lg:hidden text-muted-foreground hover:text-foreground h-9 w-9 -ml-2"
onClick={() => setIsMobileMenuOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<div className="flex items-center gap-3">
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block shadow-[0_0_8px_rgba(20,184,166,0.4)]"></div>
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block theme-glow-primary"></div>
<h1 className="text-lg font-semibold tracking-tight text-foreground">{currentTitle}</h1>
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full border border-border/50 bg-zinc-900/50">
<div className="theme-control-pill hidden sm:flex">
<div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-success"></span>
</div>
<span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">System Online</span>
<span className="font-mono uppercase tracking-wider">System Online</span>
</div>
<div className="h-6 w-px bg-border/50 hidden sm:block"></div>
<div className="hidden md:flex items-center">
<Select
value={palette}
onValueChange={(value) => {
if (isColorPalette(value)) {
setPalette(value);
}
}}
>
<SelectTrigger
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9 p-0 justify-center [&>span]:hidden"
title="切换配色方案"
aria-label="切换配色方案"
hideIndicator
>
<Palette className="h-4 w-4 text-muted-foreground" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cobalt" description="默认 · 钴蓝冷静">Cobalt Blue</SelectItem>
<SelectItem value="zinc" description="shadcn · 中性灰阶">Zinc Neutral</SelectItem>
<SelectItem value="nord" description="Nord · Arctic Blue">Nord</SelectItem>
<SelectItem value="tokyo-night" description="Tokyo Night · Neon Indigo">Tokyo Night</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="ghost"
size="icon"
className="rounded-full border border-border/50 bg-zinc-900 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/20 transition-all h-9 w-9"
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
title={resolvedTheme === 'dark' ? '切换为浅色主题' : '切换为深色主题'}
>
{resolvedTheme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="icon"
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-danger/10 hover:text-danger hover:border-danger/30 transition-all h-9 w-9"
onClick={handleLogout}
title="登出"
>
@@ -158,8 +204,8 @@ export default function DashboardPage() {
{/* Page Content */}
<main className="flex-1 overflow-y-auto relative">
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] -z-10"></div>
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${isConfigPage ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -z-10"></div>
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<Outlet />
</div>
</main>

View File

@@ -21,7 +21,7 @@ export function LoginPage() {
} else {
setError('登录失败,返回的 token 为空。');
}
} catch (err) {
} catch {
setError('登录失败,请检查密码是否正确或查看服务日志。');
} finally {
setIsLoading(false);
@@ -29,28 +29,28 @@ export function LoginPage() {
};
return (
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-zinc-950">
<div className="theme-shell-gradient relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-background">
{/* Background grid and gradient effects */}
<div className="absolute inset-0 bg-grid-pattern opacity-10"></div>
<div className="absolute top-[-20%] left-[-10%] h-[500px] w-[500px] rounded-full bg-primary/20 blur-[120px] pointer-events-none"></div>
<div className="absolute bottom-[-20%] right-[-10%] h-[500px] w-[500px] rounded-full bg-primary/10 blur-[100px] pointer-events-none"></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.04]"></div>
<div className="absolute top-[-25%] left-[-14%] h-[460px] w-[460px] rounded-full bg-primary/14 blur-[120px] pointer-events-none"></div>
<div className="absolute bottom-[-26%] right-[-12%] h-[460px] w-[460px] rounded-full bg-accent/20 blur-[120px] pointer-events-none"></div>
<div className="z-10 w-full max-w-md px-4 sm:px-6 relative">
<div className="glass-panel relative rounded-2xl p-8 sm:p-10 transition-all duration-500 hover:border-primary/20">
<div className="theme-card-shell theme-interactive-elevate relative p-8 sm:p-10">
{/* Decorative terminal dots */}
<div className="absolute top-4 left-4 flex gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-rose-500/80 shadow-[0_0_5px_rgba(244,63,94,0.5)]"></div>
<div className="h-2.5 w-2.5 rounded-full bg-amber-500/80 shadow-[0_0_5px_rgba(245,158,11,0.5)]"></div>
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/80 shadow-[0_0_5px_rgba(16,185,129,0.5)]"></div>
<div className="h-2.5 w-2.5 rounded-full bg-danger/80 theme-glow-danger"></div>
<div className="h-2.5 w-2.5 rounded-full bg-warning/80 theme-glow-warning"></div>
<div className="h-2.5 w-2.5 rounded-full bg-success/80 theme-glow-success"></div>
</div>
<div className="mb-10 mt-6 flex flex-col items-center text-center">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-zinc-900 border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.15)] ring-1 ring-primary/10 relative group">
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/70 border border-primary/20 theme-glow-primary ring-1 ring-primary/10 relative group">
<div className="absolute inset-0 rounded-2xl bg-accent/80 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<Bot className="h-8 w-8 text-primary relative z-10" />
</div>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-white sm:text-3xl">Gitea AI Assistant</h1>
<div className="flex items-center gap-2 text-xs font-mono text-primary/70 bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">Gitea AI Assistant</h1>
<div className="theme-control-pill text-primary/80">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
@@ -62,7 +62,7 @@ export function LoginPage() {
<div className="grid gap-5">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="password" className="text-xs font-mono font-medium text-zinc-400 flex items-center gap-2">
<label htmlFor="password" className="text-xs font-mono font-medium text-muted-foreground flex items-center gap-2">
<span className="text-primary font-bold">&gt;</span> enter_admin_password
</label>
</div>
@@ -75,14 +75,14 @@ export function LoginPage() {
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleLogin()}
required
placeholder="••••••••"
className="h-12 border-zinc-800 bg-zinc-900/50 font-mono text-zinc-100 placeholder:text-zinc-700 focus-visible:border-primary/50 focus-visible:ring-primary/20 transition-all duration-300"
className="theme-input-surface h-12 font-mono placeholder:text-muted-foreground/50 transition-all duration-300"
/>
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600 transition-colors group-focus-within:text-primary/70" />
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary/70" />
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-3 text-sm text-rose-400 animate-in fade-in slide-in-from-top-1">
<div className="theme-error-panel flex items-start gap-2 px-3 py-3 text-sm animate-in fade-in slide-in-from-top-1">
<Activity className="h-4 w-4 mt-0.5 shrink-0" />
<p className="font-mono text-xs leading-relaxed">{error}</p>
</div>
@@ -94,7 +94,7 @@ export function LoginPage() {
className="tech-glow group relative mt-4 h-12 w-full overflow-hidden bg-primary text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-70 disabled:pointer-events-none"
>
<div className="absolute inset-0 flex h-full w-full justify-center [transform:skew(-12deg)_translateX(-150%)] group-hover:duration-1000 group-hover:[transform:skew(-12deg)_translateX(150%)]">
<div className="relative h-full w-12 bg-white/20"></div>
<div className="relative h-full w-12 bg-foreground/20"></div>
</div>
<span className="relative flex items-center gap-2 font-mono font-semibold tracking-wide">
{isLoading ? (

View File

@@ -1,6 +1,6 @@
import api from '@/lib/api';
export type ConfigSource = 'default' | 'env' | 'override';
export type ConfigSource = 'default' | 'db';
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
export interface ConfigFieldDto {
@@ -9,8 +9,6 @@ export interface ConfigFieldDto {
description: string;
type: ConfigFieldType;
sensitive: boolean;
readonly?: boolean;
readonlyWarning?: string;
enumValues?: string[];
min?: number;
max?: number;

View File

@@ -0,0 +1,91 @@
import api from '@/lib/api';
export type ProviderType = 'openai_compatible' | 'openai_responses' | 'anthropic' | 'gemini';
export interface ProviderDto {
id: string;
name: string;
type: ProviderType;
baseUrl: string | null;
defaultModel: string;
isEnabled: boolean;
hasKey: boolean;
extraConfig: Record<string, unknown>;
createdAt: string;
updatedAt?: string;
}
export interface RoleAssignmentDto {
role: string;
providerId: string | null;
providerName: string | null;
providerType: string | null;
model: string | null;
}
export interface TestResult {
success: boolean;
latencyMs?: number;
model?: string;
message?: string;
error?: string;
}
/** Fallback suggestions when API is unavailable (e.g. catalog not loaded yet). */
const FALLBACK_SUGGESTIONS: Record<ProviderType, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'deepseek-chat'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
};
export const fetchModelSuggestions = async (): Promise<Record<string, string[]>> => {
try {
const response = await api.get<Record<string, string[]>>('/llm/model-suggestions');
return response.data;
} catch {
return FALLBACK_SUGGESTIONS;
}
};
export const fetchProviders = async (): Promise<ProviderDto[]> => {
const response = await api.get<ProviderDto[]>('/llm/providers');
return response.data;
};
export const createProvider = async (data: Partial<ProviderDto> & { apiKey?: string }): Promise<ProviderDto> => {
const response = await api.post<ProviderDto>('/llm/providers', data);
return response.data;
};
export const updateProvider = async (id: string, data: Partial<ProviderDto>): Promise<ProviderDto> => {
const response = await api.put<ProviderDto>(`/llm/providers/${id}`, data);
return response.data;
};
export const deleteProvider = async (id: string): Promise<void> => {
await api.delete(`/llm/providers/${id}`);
};
export const setApiKey = async (id: string, apiKey: string): Promise<void> => {
await api.put(`/llm/providers/${id}/key`, { apiKey });
};
export const deleteApiKey = async (id: string): Promise<void> => {
await api.delete(`/llm/providers/${id}/key`);
};
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
return response.data;
};
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
return response.data;
};
export const testProvider = async (id: string): Promise<TestResult> => {
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
return response.data;
};

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

View File

@@ -34,6 +34,22 @@ module.exports = {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
danger: {
DEFAULT: "hsl(var(--danger))",
foreground: "hsl(var(--danger-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
info: {
DEFAULT: "hsl(var(--info))",
foreground: "hsl(var(--info-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",

View File

@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test';
import { installVisualApiMocks } from './fixtures/mockApi';
import { applyThemeAndAuth, installVisualNetworkGuards, stabilizeVisualState, waitForThemeReady, type VisualPalette } from './fixtures/stabilize';
type VisualCase = {
name: string;
path: string;
authToken?: string;
readySelectors: string[];
};
const protectedToken = 'visual-token';
const visualCases: VisualCase[] = [
{
name: 'login',
path: '/',
readySelectors: ['#password', 'button:has-text("AUTHORIZE")'],
},
{
name: 'repos',
path: '/repos',
authToken: protectedToken,
readySelectors: ['text=仓库名称', 'text=demo-repo-1'],
},
{
name: 'config',
path: '/config',
authToken: protectedToken,
readySelectors: ['button:has-text("保存配置")', 'text=Gitea 连接'],
},
{
name: 'review-config',
path: '/review-config',
authToken: protectedToken,
readySelectors: ['text=审查引擎', 'button:has-text("保存配置")'],
},
];
const themes: Array<'light' | 'dark'> = ['light', 'dark'];
const palettes: VisualPalette[] = ['cobalt', 'zinc', 'nord', 'tokyo-night'];
for (const visualCase of visualCases) {
for (const theme of themes) {
for (const palette of palettes) {
test(`${visualCase.name} ${theme} ${palette} baseline`, async ({ page }) => {
await installVisualApiMocks(page);
await installVisualNetworkGuards(page);
await applyThemeAndAuth(page, theme, palette, visualCase.authToken);
await page.goto(visualCase.path, { waitUntil: 'networkidle' });
await waitForThemeReady(page, theme, palette);
for (const selector of visualCase.readySelectors) {
await page.locator(selector).first().waitFor({ state: 'visible' });
}
await stabilizeVisualState(page);
const snapshotName =
palette === 'cobalt'
? `${visualCase.name}-${theme}.png`
: `${visualCase.name}-${theme}-${palette}.png`;
await expect(page).toHaveScreenshot(snapshotName, {
fullPage: true,
});
});
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,335 @@
import type { Page, Route } from '@playwright/test';
const repositories = {
data: [
{ name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 },
{ name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null },
],
totalCount: 2,
page: 1,
limit: 30,
};
const configResponse = {
groups: [
{
key: 'gitea',
label: 'Gitea 连接',
description: '配置 Gitea 服务地址和访问凭据。',
icon: 'git-branch',
fields: [
{
envKey: 'GITEA_BASE_URL',
label: 'Gitea 地址',
description: 'Gitea API 根地址',
type: 'url',
sensitive: false,
value: 'https://gitea.example.com',
hasValue: true,
source: 'db',
},
],
},
{
key: 'feishu',
label: '飞书通知',
description: '配置飞书 webhook 通知。',
icon: 'bell',
fields: [
{
envKey: 'FEISHU_WEBHOOK_URL',
label: '飞书 Webhook URL',
description: '用于发送审查通知',
type: 'url',
sensitive: false,
value: 'https://open.feishu.cn/mock/webhook',
hasValue: true,
source: 'db',
},
],
},
{
key: 'security',
label: '安全设置',
description: '控制签名校验与访问策略。',
icon: 'shield',
fields: [
{
envKey: 'ENABLE_SIGNATURE_VERIFY',
label: '启用签名校验',
description: '是否开启 webhook 签名验证',
type: 'boolean',
sensitive: false,
value: true,
hasValue: true,
source: 'db',
},
],
},
{
key: 'review',
label: '审查设置',
description: '控制审查引擎与执行策略。',
icon: 'search',
fields: [
{
envKey: 'REVIEW_ENGINE',
label: '审查引擎',
description: '当前使用的审查引擎',
type: 'enum',
enumValues: ['agent', 'codex'],
sensitive: false,
value: 'agent',
hasValue: true,
source: 'db',
},
{
envKey: 'GLOBAL_PROMPT',
label: '全局提示词',
description: '审查上下文提示词模板',
type: 'text',
sensitive: false,
value: 'Review with focus on correctness and maintainability.',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_WORKDIR',
label: '审查工作目录',
description: '任务执行工作目录',
type: 'string',
sensitive: false,
value: '/tmp/review-workdir',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
label: '最大并发任务',
description: '控制并发执行数量',
type: 'number',
sensitive: false,
value: 4,
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_MAX_FILES_PER_RUN',
label: '单次最大文件数',
description: '每轮审查最大文件数',
type: 'number',
sensitive: false,
value: 40,
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_MAX_FILE_CONTENT_CHARS',
label: '单文件最大字符',
description: '单文件读取上限',
type: 'number',
sensitive: false,
value: 20000,
hasValue: true,
source: 'db',
},
{
envKey: 'LLM_MAX_CONCURRENT_CALLS',
label: 'LLM 并发调用数',
description: '限制并发调用',
type: 'number',
sensitive: false,
value: 4,
hasValue: true,
source: 'db',
},
{
envKey: 'CODEX_MODEL',
label: 'Codex 模型',
description: 'Codex 模式默认模型',
type: 'string',
sensitive: false,
value: 'gpt-4o-mini',
hasValue: true,
source: 'db',
},
{
envKey: 'CODEX_API_URL',
label: 'Codex API 地址',
description: 'Codex 服务地址',
type: 'url',
sensitive: false,
value: 'https://api.openai.com/v1',
hasValue: true,
source: 'db',
},
],
},
{
key: 'memory',
label: '记忆设置',
description: '控制上下文记忆与保留策略。',
icon: 'database',
fields: [
{
envKey: 'MEMORY_ENABLED',
label: '启用记忆',
description: '是否启用长期记忆',
type: 'boolean',
sensitive: false,
value: true,
hasValue: true,
source: 'db',
},
],
},
],
};
const providers = [
{
id: 'provider-openai',
name: 'OpenAI',
type: 'openai_responses',
baseUrl: null,
defaultModel: 'gpt-4o-mini',
isEnabled: true,
hasKey: true,
extraConfig: {},
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
},
{
id: 'provider-deepseek',
name: 'DeepSeek',
type: 'openai_compatible',
baseUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
isEnabled: true,
hasKey: true,
extraConfig: {},
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
},
];
const roles = [
{
role: 'planner',
providerId: 'provider-openai',
providerName: 'OpenAI',
providerType: 'openai_responses',
model: 'gpt-4o-mini',
},
{
role: 'specialist',
providerId: 'provider-deepseek',
providerName: 'DeepSeek',
providerType: 'openai_compatible',
model: 'deepseek-chat',
},
];
const modelSuggestions = {
openai_compatible: ['deepseek-chat', 'gpt-4o-mini'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514'],
gemini: ['gemini-2.5-pro'],
};
const json = async (route: Route, body: unknown, status = 200) => {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
};
export async function installVisualApiMocks(page: Page) {
await page.route('**/admin/api/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (method === 'POST' && path.endsWith('/admin/api/login')) {
return json(route, { token: 'visual-token' });
}
if (method === 'GET' && path.endsWith('/admin/api/repositories')) {
return json(route, repositories);
}
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) {
return json(route, { hook_id: 101, webhook_status: 'active' });
}
if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'GET' && path.endsWith('/admin/api/config')) {
return json(route, configResponse);
}
if (method === 'PUT' && path.endsWith('/admin/api/config')) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'POST' && path.endsWith('/admin/api/config/reset')) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
return json(route, modelSuggestions);
}
if (method === 'GET' && path.endsWith('/admin/api/llm/providers')) {
return json(route, providers);
}
if (method === 'POST' && path.endsWith('/admin/api/llm/providers')) {
return json(route, providers[0]);
}
if (method === 'PUT' && /\/admin\/api\/llm\/providers\/[^/]+$/.test(path)) {
return json(route, providers[0]);
}
if (method === 'DELETE' && /\/admin\/api\/llm\/providers\/[^/]+$/.test(path)) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'PUT' && /\/admin\/api\/llm\/providers\/[^/]+\/key$/.test(path)) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'DELETE' && /\/admin\/api\/llm\/providers\/[^/]+\/key$/.test(path)) {
return route.fulfill({ status: 204, body: '' });
}
if (method === 'GET' && path.endsWith('/admin/api/llm/roles')) {
return json(route, roles);
}
if (method === 'PUT' && /\/admin\/api\/llm\/roles\/[^/]+$/.test(path)) {
return json(route, roles[0]);
}
if (method === 'POST' && /\/admin\/api\/llm\/providers\/[^/]+\/test$/.test(path)) {
return json(route, {
success: true,
latencyMs: 154,
model: 'gpt-4o-mini',
message: 'Test connection success',
});
}
return json(
route,
{
error: `Unhandled visual mock request: ${method} ${path}`,
},
501
);
});
}

View File

@@ -0,0 +1,32 @@
.animate-ping,
.animate-pulse,
.animate-spin {
animation: none !important;
}
html,
body,
button,
input,
textarea,
select {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Helvetica Neue', Arial, sans-serif !important;
text-rendering: geometricPrecision !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
}
code,
pre,
.font-mono {
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
}
.bg-grid-pattern {
opacity: 0.04 !important;
}
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}

View File

@@ -0,0 +1,71 @@
import type { Page } from '@playwright/test';
export type VisualPalette = 'cobalt' | 'zinc' | 'nord' | 'tokyo-night';
const STABILIZE_STYLE = `
*, *::before, *::after {
transition-property: none !important;
transition-duration: 0s !important;
animation-duration: 0s !important;
animation-delay: 0s !important;
caret-color: transparent !important;
}
`;
export async function stabilizeVisualState(page: Page) {
await page.addStyleTag({ content: STABILIZE_STYLE });
await page.waitForLoadState('networkidle');
await page.evaluate(() => {
window.scrollTo(0, 0);
});
await page.evaluate(async () => {
if (document.fonts) {
await document.fonts.ready;
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
export async function installVisualNetworkGuards(page: Page) {
await page.route('https://fonts.googleapis.com/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/css',
body: '',
});
});
await page.route('https://fonts.gstatic.com/**', async (route) => {
await route.fulfill({ status: 204, body: '' });
});
}
export async function waitForThemeReady(page: Page, theme: 'light' | 'dark', palette: VisualPalette) {
await page.waitForFunction(({ expectedTheme, expectedPalette }) => {
const isDark = document.documentElement.classList.contains('dark');
const currentPalette = document.documentElement.getAttribute('data-palette') ?? 'cobalt';
const themeReady = expectedTheme === 'dark' ? isDark : !isDark;
return themeReady && currentPalette === expectedPalette;
}, { expectedTheme: theme, expectedPalette: palette });
}
export async function applyThemeAndAuth(
page: Page,
theme: 'light' | 'dark',
palette: VisualPalette,
authToken?: string
) {
await page.addInitScript(
({ selectedTheme, selectedPalette, token }) => {
localStorage.setItem('theme', selectedTheme);
localStorage.setItem('ui-color-palette', selectedPalette);
if (token) {
localStorage.setItem('authToken', token);
} else {
localStorage.removeItem('authToken');
}
},
{ selectedTheme: theme, selectedPalette: palette, token: authToken }
);
}

17
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import path from 'path';
import { configDefaults, defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'happy-dom',
setupFiles: ['./src/test-setup.ts'],
exclude: [...configDefaults.exclude, 'tests/visual/**'],
},
});

97
k8s/gitea-assistant.yaml Normal file
View File

@@ -0,0 +1,97 @@
---
# ConfigMap: only infrastructure-level env vars that must be known before DB init
apiVersion: v1
kind: ConfigMap
metadata:
name: gitea-assistant-config
namespace: gitea-assistant
labels:
app.kubernetes.io/name: gitea-assistant
app.kubernetes.io/part-of: gitea-assistant
data:
PORT: "3000"
# All settings (Gitea connection, webhook secret, admin password, review engine,
# Feishu, memory, etc.) are managed through the Admin Dashboard Web UI.
# They are auto-seeded with secure defaults on first boot.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-assistant
namespace: gitea-assistant
labels:
app.kubernetes.io/name: gitea-assistant
app.kubernetes.io/part-of: gitea-assistant
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: gitea-assistant
template:
metadata:
labels:
app.kubernetes.io/name: gitea-assistant
app.kubernetes.io/part-of: gitea-assistant
spec:
containers:
- name: gitea-assistant
image: ghcr.io/jeffusion/gitea-ai-assistant:latest
ports:
- name: http
containerPort: 3000
protocol: TCP
envFrom:
- configMapRef:
name: gitea-assistant-config
- secretRef:
name: gitea-assistant-secret
resources:
limits:
memory: "512Mi"
requests:
memory: "256Mi"
cpu: "100m"
volumeMounts:
- name: data
mountPath: /app/data
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
hostPath:
# Customize this path to match your node's storage layout
path: /opt/gitea-assistant/data
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: gitea-assistant
namespace: gitea-assistant
labels:
app.kubernetes.io/name: gitea-assistant
app.kubernetes.io/part-of: gitea-assistant
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: gitea-assistant
ports:
- name: http
port: 3000
targetPort: http
protocol: TCP

10
k8s/kustomization.yaml Normal file
View File

@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: gitea-assistant
resources:
- namespace.yaml
- secret.yaml
- qdrant.yaml
- gitea-assistant.yaml

6
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: gitea-assistant
labels:
app.kubernetes.io/part-of: gitea-assistant

84
k8s/qdrant.yaml Normal file
View File

@@ -0,0 +1,84 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: qdrant
namespace: gitea-assistant
labels:
app.kubernetes.io/name: qdrant
app.kubernetes.io/part-of: gitea-assistant
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: qdrant
template:
metadata:
labels:
app.kubernetes.io/name: qdrant
app.kubernetes.io/part-of: gitea-assistant
spec:
containers:
- name: qdrant
image: qdrant/qdrant:latest
ports:
- name: http
containerPort: 6333
protocol: TCP
- name: grpc
containerPort: 6334
protocol: TCP
resources:
limits:
memory: "1Gi"
requests:
memory: "512Mi"
cpu: "250m"
volumeMounts:
- name: qdrant-storage
mountPath: /qdrant/storage
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: qdrant-storage
hostPath:
# Customize this path to match your node's storage layout
path: /opt/gitea-assistant/qdrant
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: qdrant
namespace: gitea-assistant
labels:
app.kubernetes.io/name: qdrant
app.kubernetes.io/part-of: gitea-assistant
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: qdrant
ports:
- name: http
port: 6333
targetPort: http
protocol: TCP
- name: grpc
port: 6334
targetPort: grpc
protocol: TCP

Some files were not shown because too many files have changed in this diff Show More