84 Commits

Author SHA1 Message Date
jeffusion
d442e193dd fix(review-agent): complete fingerprint migration — dual-index all three keys
- existingPublished now indexes finding.fingerprint, modern, and legacy
- rememberPublished merges published status with true-wins semantics
- regression test: legacy published finding stays published after migration to modern fingerprint
2026-05-27 00:04:16 +08:00
jeffusion
7d6794f368 fix(agent-kernel): address Oracle review round 2 findings
- tryParseFinalSubmission: return result when summaryMarkdown is non-empty even with empty findings
- consecutiveToolFailures: append tool result to transcript before checking termination threshold
- fingerprint: add buildLegacyFingerprint and dual-index existingPublished for migration compatibility
- regression tests: empty findings+summary, clip newline boundary, fingerprint format migration
2026-05-26 23:42:13 +08:00
jeffusion
6c83e12bf5 feat(agent-kernel): cherry-pick high-value components from PR #15
- Zod findingResponseSchema + LLM repair for malformed model output (max 2 attempts)
- Budget guards: maxSubagents, maxEmptyResponses, maxConsecutiveToolFailures
  - maxSubagents: refuse spawn at limit, allow model to summarize
  - consecutiveToolFailures: per-tool-call update, reset on success, immediate terminate
- Tool permission scope system (6 scopes, allow/deny static policy, no ask)
  - allowListSpecified flag distinguishes subagent vs main agent resolution
- SHA256 finding fingerprint with JSON tuple input (avoids colon ambiguity)
- Token counting + context clipping via tokenlens (newline-boundary clipping)
- diffBudget floor of 1000 tokens (prevents negative budget for small models)
- tryParseFinalSubmission: full JSON first (preserves summaryMarkdown), Zod fallback
- normalizeFinding: Zod-only validation, no lax fallback
- E2E README: fix webhook signing (--data-binary, openssl dgst, repository.name)
2026-05-26 22:43:56 +08:00
jeffusion
bc1dfb6dde refactor: replace fixed review workflow with dynamic agent framework
- Add agent-kernel runtime (MainAgentRunner with while-true state machine,
  subagent spawning, tool loop, budget control)
- Add review-agent entrypoint with read_file/search_code/spawn_subagent tools
- Add deterministic publish adapter with cross-specialist finding dedup
- Delete old fixed workflow (orchestrator, triage, specialist, judge agents,
  4 domain agents, critic/debate/reflexion agents, learning/memory system)
- Remove legacy ModelRole (planner/specialist/judge) from LLM types, gateway,
  config schema, DB, and frontend RoleAssignment UI
- Replace RoleAssignment with AgentModelSettings for role-based model config
- Add agent config API endpoints (GET/PUT /admin/api/agents/config)
- Add review session detail page with observability/findings/logs tabs
- Add runtime contract tests and review adapter integration tests
- Add E2E mock LLM with scripted behavior support for deterministic testing
- Update E2E test script with subagent and finding assertions
- Add e2e/README.md with real PR review testing guide
- Fix seed.sh to run gitea admin commands as git user (not root)
- Update docs (configuration, review-engines, deployment, README)
- Remove unused feedback controller, qdrant k8s manifest, embedding migration
- Add .omo/ and .opencode/ to .gitignore
2026-05-26 15:45:22 +08:00
semantic-release-bot
8d6d167b33 chore(release): 1.3.1 [skip ci]
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)

### Bug Fixes

* **db:** self-heal missing repository prompt schema ([b6e6ee0](b6e6ee0927))
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](3a97d673f6))
* **repo:** add structured diagnostics for repository list failures ([22b6032](22b603258a))
2026-03-26 15:52:17 +00:00
jeffusion
1e7c80ca9f docs: document LOG_LEVEL configuration and production defaults
Update all documentation to reflect new global LOG_LEVEL environment variable.

- Add LOG_LEVEL to configuration reference tables

- Update deployment guides with LOG_LEVEL=error examples

- Clarify dev (info) vs production (error) log level recommendations

- Add LOG_LEVEL to all .env examples and quick start guides

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b92765ce7f chore(deploy): set production LOG_LEVEL to error
Configure production deployments to use LOG_LEVEL=error for minimal log volume.

- Add LOG_LEVEL=error to docker-compose.yml environment

- Add LOG_LEVEL: error to K8s ConfigMap

- Update .env.example with dev/prod LOG_LEVEL guidance

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
daae32ce07 chore(deps): add pino for structured logging
Add pino v10.3.1 and its dependencies to support global LOG_LEVEL logging.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
ab984ff415 refactor(logger): migrate to pino with global LOG_LEVEL control
Replace custom console-based logger with pino backend supporting LOG_LEVEL environment variable.

- Add pino dependency for structured JSON logging

- Implement LOG_LEVEL env var support (debug/info/warn/error, default: info)

- Remove REPO_LIST_DEBUG_LOGS special flag in favor of global LOG_LEVEL

- Preserve existing logger API compatibility (message, meta?)

- Add safe error serialization to prevent credential leakage

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
d49a16db6e test(db): add self-healing tests for missing repository prompt table
- Test runtime self-healing when repository_review_prompts table is dropped

- Test migration layer self-healing for inconsistent DB state

- Verify repository listing remains functional during schema recovery

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
3a97d673f6 fix(logs): gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag
- Add REPO_LIST_DEBUG_LOGS environment variable to control debug output

- Gate debug logs in admin controller and gitea service

- Keep error/warn logs always enabled for production visibility

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b6e6ee0927 fix(db): self-heal missing repository prompt schema
Recover inconsistent SQLite states where migration v3 is marked applied but repository_review_prompts objects are absent, preventing admin repository listing failures in docker deployments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
22b603258a fix(repo): add structured diagnostics for repository list failures
Capture request/runtime context plus nested error metadata so docker-only repository-list issues can be diagnosed quickly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
semantic-release-bot
1885004874 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)

### Features

* **repo:** add project-level review prompt with UI redesign ([d5deb75](d5deb75231))
2026-03-26 05:36:05 +00:00
jeffusion
d5deb75231 feat(repo): add project-level review prompt with UI redesign
- Add database migration and repository for project review prompts
- Add API endpoint for setting project-level prompts
- Integrate project prompts into Agent and Codex review flows
- Redesign repository management UI with dialog-based prompt editor
- Replace flat buttons with Switch for webhook toggle and dedicated prompt button
- Add Dialog and DropdownMenu UI components from Radix UI
- Add comprehensive tests for wiring and interactions
2026-03-26 13:35:05 +08:00
jeffusion
c313764b61 docs(readme): reorganize docs and screenshot gallery
Align project docs with current behavior using progressive disclosure and bilingual deep-dive guides. Add per-page admin screenshots with consistent page-* naming to make UI documentation clearer.
2026-03-24 16:04:57 +08:00
semantic-release-bot
63f419228e chore(release): 1.2.1 [skip ci]
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)

### Bug Fixes

* **ci:** source Docker tags from semantic-release version ([f84c0ab](f84c0ab777))
2026-03-24 07:29:39 +00:00
jeffusion
f84c0ab777 fix(ci): source Docker tags from semantic-release version
Avoid stale image tags from placeholder package.json and prevent prereleases from overwriting latest.
2026-03-24 15:06:48 +08:00
semantic-release-bot
7792a78c00 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)

### Bug Fixes

* **lint:** apply biome cleanup for notification modules ([7aec1e4](7aec1e452a))

### Features

* **frontend:** add dedicated notification management menu and test panel ([9964614](9964614b5e))
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](e40daddf0d))
2026-03-24 05:40:56 +00:00
jeffusion
7aec1e452a fix(lint): apply biome cleanup for notification modules 2026-03-24 13:40:06 +08:00
jeffusion
8f9910a3fd refactor(notification): replace static factory class with function exports 2026-03-24 13:40:06 +08:00
jeffusion
2392808b82 chore(dev): bootstrap frontend dependencies from root install 2026-03-24 13:40:06 +08:00
jeffusion
9567501369 chore(deploy): standardize assistant default port to 5174 2026-03-24 13:40:06 +08:00
jeffusion
9964614b5e feat(frontend): add dedicated notification management menu and test panel 2026-03-24 13:40:06 +08:00
jeffusion
e40daddf0d feat(notification): replace feishu-only flow with pluggable providers 2026-03-24 13:40:06 +08:00
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
277 changed files with 26880 additions and 6699 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,14 @@
# Gitea配置
GITEA_API_URL=http://localhost:3000/api/v1
GITEA_ACCESS_TOKEN=your_gitea_token
# OpenAI配置
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
CUSTOM_SUMMARY_PROMPT=your_custom_prompt
CUSTOM_LINE_COMMENT_PROMPT=your_custom_prompt
# 飞书配置
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_webhook_token
FEISHU_WEBHOOK_SECRET=your_webhook_secret
# 应用配置
PORT=3000
# 建议使用以下命令生成一个安全的随机字符串作为webhook密钥:
# 在Linux/Mac终端: openssl rand -hex 32
# 或者在Node.js中: require('crypto').randomBytes(32).toString('hex')
WEBHOOK_SECRET=your_webhook_secret
PORT=5174
# 可选,默认为 ./data/assistant.db
# DATABASE_PATH=./data/assistant.db
# 可选,默认 info可选值debug/info/warn/error
# 开发环境建议LOG_LEVEL=info
# 生产环境建议LOG_LEVEL=error
# LOG_LEVEL=info
# 必填,运行 openssl rand -hex 32 生成
ENCRYPTION_KEY=
# Agent审查配置默认关闭开启请设置为agent
REVIEW_ENGINE=legacy
REVIEW_WORKDIR=/tmp/gitea-assistant
REVIEW_MODEL_PLANNER=gpt-4o-mini
REVIEW_MODEL_SPECIALIST=gpt-4o-mini
REVIEW_MODEL_JUDGE=gpt-4o-mini
REVIEW_MAX_PARALLEL_RUNS=2
REVIEW_MAX_FILES_PER_RUN=200
REVIEW_MAX_FILE_CONTENT_CHARS=40000
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.8
REVIEW_ENABLE_HUMAN_GATE=true
REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
REVIEW_COMMAND_TIMEOUT_MS=10000
# 向量记忆和学习系统配置(可选,第二阶段功能)
# Qdrant向量数据库URL如果不配置则禁用记忆系统
QDRANT_URL=http://localhost:6333
# 是否启用记忆系统需要先配置QDRANT_URL
ENABLE_MEMORY=false
# Few-shot学习示例数量0-20
FEW_SHOT_EXAMPLES_COUNT=10
# Reflection和Debate配置可选第三阶段功能
# 是否启用Reflection自我批评机制提升审查质量
ENABLE_REFLECTION=false
# Reflection最大轮次1-5
MAX_REFLECTION_ROUNDS=2
# 是否启用Debate多代理辩论机制提升高严重性问题准确性
ENABLE_DEBATE=false
# Debate触发阈值high=仅高严重性, medium=高和中等严重性)
DEBATE_THRESHOLD=high
# 所有其他配置Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等
# 均通过 Web 管理后台进行配置。
# 启动服务后访问 http://localhost:5174 进行配置。

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/

View File

@@ -40,35 +40,66 @@ jobs:
run: bun test
- name: Run semantic-release
run: bunx semantic-release
id: semantic
uses: codfish/semantic-release-action@v5
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
HUSKY: 0
HUSKY_SKIP_HOOKS: 1
# Docker build and push
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: steps.semantic.outputs.new-release-published == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from package.json
id: package-version
- name: Derive Docker tags from semantic-release
if: steps.semantic.outputs.new-release-published == 'true'
id: docker-tags
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Detected version: $VERSION"
VERSION="${{ steps.semantic.outputs.release-version }}"
if [ -z "$VERSION" ]; then
echo "semantic-release did not provide release-version" >&2
exit 1
fi
if [[ "$VERSION" == *"-"* ]]; then
TAGS="ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}"
else
MAJOR="${VERSION%%.*}"
REST="${VERSION#*.}"
MINOR="${REST%%.*}"
TAGS=$(printf '%s\n' \
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest" \
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}" \
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}.${MINOR}" \
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}")
fi
{
echo "tags<<EOF"
echo "$TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Release version: ${VERSION}"
echo "Docker tags to push:"
echo "$TAGS"
- name: Build and push Docker image
if: steps.semantic.outputs.new-release-published == 'true'
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${{ steps.package-version.outputs.version }}
tags: ${{ steps.docker-tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

16
.gitignore vendored
View File

@@ -4,3 +4,19 @@ dist/
config-overrides.json
.sisyphus/
e2e/.env.e2e
# Runtime data
data/
# Frontend build artifacts
public/
# Test temporaries
/tmp/
*.db-shm
*.db-wal
# Lock files (frontend has its own)
frontend/package-lock.json
.omo/
.opencode/

4
.husky/pre-commit Executable file
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,90 @@
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
### Bug Fixes
* **db:** self-heal missing repository prompt schema ([b6e6ee0](https://github.com/jeffusion/gitea-ai-assistant/commit/b6e6ee0927eb757b86ee426bf8eed84ae633621a))
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](https://github.com/jeffusion/gitea-ai-assistant/commit/3a97d673f671752a2e7f676fb0074b413c2e40cc))
* **repo:** add structured diagnostics for repository list failures ([22b6032](https://github.com/jeffusion/gitea-ai-assistant/commit/22b603258ac32e70653aa1a05032a91e8ad23f89))
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)
### Features
* **repo:** add project-level review prompt with UI redesign ([d5deb75](https://github.com/jeffusion/gitea-ai-assistant/commit/d5deb752317508aa47470a20fec4d11a5d2b66b7))
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)
### Bug Fixes
* **ci:** source Docker tags from semantic-release version ([f84c0ab](https://github.com/jeffusion/gitea-ai-assistant/commit/f84c0ab7770b73032b331c82cf8f87f1e8b281ff))
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)
### Bug Fixes
* **lint:** apply biome cleanup for notification modules ([7aec1e4](https://github.com/jeffusion/gitea-ai-assistant/commit/7aec1e452a04d3dbf935837e9b8e96107466c487))
### Features
* **frontend:** add dedicated notification management menu and test panel ([9964614](https://github.com/jeffusion/gitea-ai-assistant/commit/9964614b5ebb7972e2b35f3fc673f626372f6552))
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](https://github.com/jeffusion/gitea-ai-assistant/commit/e40daddf0dd168c19251cdb84a3b6b136814f553))
## [1.1.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.0...v1.1.1) (2026-03-24)
### Bug Fixes
* **build:** guard husky prepare for production installs ([5aeff75](https://github.com/jeffusion/gitea-ai-assistant/commit/5aeff7585b465fa9479c538b67b99978d12455b1))
# [1.1.0](https://github.com/Jeffusion/gitea-ai-assistant/compare/v1.0.0...v1.1.0) (2026-03-24)
### Bug Fixes
* **agent:** fix rg args ordering in function reference search tool ([f410373](https://github.com/Jeffusion/gitea-ai-assistant/commit/f410373f7b1b935047fb5f50fad7c95a870704a8))
* **agent:** improve specialist agent JSON resilience and finding schema ([2587576](https://github.com/Jeffusion/gitea-ai-assistant/commit/2587576514b353ff3a1314b54cf85c977a66f62e))
* **ci:** stabilize visual regression environment ([9887504](https://github.com/Jeffusion/gitea-ai-assistant/commit/98875044d6b514d253a90cfcc445ac2f5253b278))
* **config:** make persistOverrides resilient to read-only filesystems ([3f2817d](https://github.com/Jeffusion/gitea-ai-assistant/commit/3f2817d6c306c024a7200f36e087dd15c97bcad8))
* **config:** silently skip readonly fields on save instead of rejecting ([12425d1](https://github.com/Jeffusion/gitea-ai-assistant/commit/12425d147f3ec0c202b2d9a347836d5515e9b702))
* **docker:** add git, ca-certificates, and ripgrep to production image ([ba26635](https://github.com/Jeffusion/gitea-ai-assistant/commit/ba2663552d77321bb91ef112fa4bbebb65a5585c))
* **frontend:** standardize favicon/title, 401 redirect, SPA root route, and theme switching ([5bb1c3a](https://github.com/Jeffusion/gitea-ai-assistant/commit/5bb1c3a2d182dcde65667c39f2a61eaf1d43b706))
* **k8s:** extract Secret to separate file to fix kustomize apply ([e3b8365](https://github.com/Jeffusion/gitea-ai-assistant/commit/e3b8365ea2992162b2f575bb84af73352ef375ca))
* **k8s:** remove stale GITEA_ACCESS_TOKEN/GITEA_API_URL/QDRANT_URL from k8s config ([9b063af](https://github.com/Jeffusion/gitea-ai-assistant/commit/9b063afba0046d50ba691efe1f45159b97c2149e))
* **k8s:** use writable emptyDir volume for config overrides ([98e5048](https://github.com/Jeffusion/gitea-ai-assistant/commit/98e5048f2c898966ab59c2c806477bac2e443869))
* **lint:** resolve biome violations across src modules ([3c1d616](https://github.com/Jeffusion/gitea-ai-assistant/commit/3c1d616dc180d53232eccca05255ca853084ab23))
* make all config consumers read dynamically instead of caching at module load ([9a356a2](https://github.com/Jeffusion/gitea-ai-assistant/commit/9a356a228f11c07495e5d60eb6c6554a42ec4434))
* make FEISHU_WEBHOOK_URL optional to prevent startup crash ([d84a0ed](https://github.com/Jeffusion/gitea-ai-assistant/commit/d84a0ed95614fc954fdf7b7f6725ede4e32d76f3))
* remove isDev branches that caused production to use mock test data ([f3ba9de](https://github.com/Jeffusion/gitea-ai-assistant/commit/f3ba9de06f5f51ebf44e13a7e1db8f9d264d9034))
* **test:** update specialist-agent-react tests for LLMGateway API ([824564d](https://github.com/Jeffusion/gitea-ai-assistant/commit/824564dac6bddd7439ef40f3c0da946d9634201f))
* **ui:** align card headers and stabilize themed layout polish ([28d86af](https://github.com/Jeffusion/gitea-ai-assistant/commit/28d86aff16f954cd18c0e46b86325a13fc6c0949))
### Features
* **config:** add Codex engine configuration fields ([129094a](https://github.com/Jeffusion/gitea-ai-assistant/commit/129094a39e77b0bb66b052ed19bb8969d1757503))
* **config:** add global prompt setting injected into all LLM calls ([afd5685](https://github.com/Jeffusion/gitea-ai-assistant/commit/afd568588d1722206314009cd0b9060125fac883))
* **config:** migrate all runtime settings from env vars to SQLite DB ([4c32a46](https://github.com/Jeffusion/gitea-ai-assistant/commit/4c32a460d39e277b1ee59fec31a171f162c83f22))
* **db:** add SQLite database layer with encrypted secret storage ([21fef99](https://github.com/Jeffusion/gitea-ai-assistant/commit/21fef999fbca56c07b93cd9109d8e7dfd50bbf31))
* **frontend:** update config UI for DB-first config architecture ([9c9ef05](https://github.com/Jeffusion/gitea-ai-assistant/commit/9c9ef05d13962586cbd0decb2377d003a8901679))
* **llm:** add LLM config REST API controller ([c6c8e20](https://github.com/Jeffusion/gitea-ai-assistant/commit/c6c8e2068331bdfe344f92f53cd6fffc7758cfac))
* **llm:** add pluggable multi-provider LLM architecture ([c9a2db3](https://github.com/Jeffusion/gitea-ai-assistant/commit/c9a2db3df2c73ef22cb2c32fd80bdf36bfa1e697))
* **llm:** add resilience layer with rate limiting and retry ([839d4a8](https://github.com/Jeffusion/gitea-ai-assistant/commit/839d4a89bfd763d7fe693c00c3aa9b1becd34c58))
* **review/codex:** add Codex review engine with MCP tools ([614f66c](https://github.com/Jeffusion/gitea-ai-assistant/commit/614f66c433a93fc2373a9bf8d2319bd5cb35473b))
* **review:** add incremental review with snapshot refs ([9308c60](https://github.com/Jeffusion/gitea-ai-assistant/commit/9308c60aa014c0dd016d984a6b3d1cfb1e3a9379))
* **review:** add token-aware context control with tokenlens ([ec2029a](https://github.com/Jeffusion/gitea-ai-assistant/commit/ec2029a94261169832477f9c7aaa4ee1ad1adefc))
* **review:** add triage agent for smart specialist routing ([86480de](https://github.com/Jeffusion/gitea-ai-assistant/commit/86480dec076661315f725b286e65d57092ceb30b))
* **review:** add workspace cleanup on PR close and scheduled stale cleanup ([792ed7f](https://github.com/Jeffusion/gitea-ai-assistant/commit/792ed7faa2b89bb0f4c84dcf032e30a34286b77b))
* **review:** remove legacy mode and harden agent/codex pipeline ([1c0c9af](https://github.com/Jeffusion/gitea-ai-assistant/commit/1c0c9afd1779a15e55edc7a854924e86cbb50cf3))
* **ui:** add frontend test infrastructure with vitest ([bc7616d](https://github.com/Jeffusion/gitea-ai-assistant/commit/bc7616df424e26d6b20105f6401329e63e9013ef))
* **ui:** add LLM provider management frontend ([c45cb34](https://github.com/Jeffusion/gitea-ai-assistant/commit/c45cb34a358393a0b6e0523fd282ee3d49fbff82))
* **ui:** add review config page with engine selector ([ae0dfce](https://github.com/Jeffusion/gitea-ai-assistant/commit/ae0dfceba1e626fddf75d60e982f81c953aaddd6))
* **ui:** replace hardcoded model lists with dynamic tokenlens API ([71bd310](https://github.com/Jeffusion/gitea-ai-assistant/commit/71bd310459901ed665eb1337d17bb4bec9ca9c3e))
# 1.0.0 (2026-03-03)

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 curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=backend-builder /app/node_modules ./node_modules
@@ -37,6 +54,9 @@ COPY --from=backend-builder /app/tsconfig.json .
COPY --from=frontend-builder /app/frontend/dist ./public
EXPOSE 3000
# Codex CLI binary (statically linked musl build)
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
EXPOSE 5174
CMD ["bun", "run", "start"]

203
README.md
View File

@@ -2,178 +2,103 @@
[![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. It receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
**[中文文档](./docs/README.zh-CN.md)**
- English docs: [./docs/README.md](./docs/README.md)
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
## Features
## Why this project
- 🤖 **AI Code Review** - Automatic review of PRs and commits using OpenAI models
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
- 🔄 **Dual Review Engines** - Legacy (simple) or Agent-based (multi-agent) review modes
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and configuration
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
- 🧠 **Two review engines**: `agent` (native Agent pipeline) and `codex` (Codex CLI pipeline)
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
- 📍 **Actionable output**: summary comments and line-level findings
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
## Architecture
## Product screenshot
> Dashboard screenshot is generated from local dev service.
![Gitea AI Assistant Dashboard - Repository Page](./docs/assets/page-repos.png)
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
## Architecture (high-level)
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
Gitea Server │────▶│ Gitea Assistant │────▶│ OpenAI API
(Webhooks) (Hono + Bun)
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ Admin Dashboard │
│ (React SPA) │
└──────────────────┘
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
+-> Admin Dashboard (React)
```
### Review Engines
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
| Engine | Description | Use Case |
|--------|-------------|----------|
| `legacy` | Single-pass AI review with summary + line comments | Simple, fast reviews |
| `agent` | Multi-agent orchestration with specialists, reflection, and debate | Deep, comprehensive analysis |
## Quick start (minimal)
## Quick Start
### 1) Prerequisites
### Prerequisites
- Bun >= 1.2.5
- Reachable Gitea instance
- At least one LLM provider credential
- [Bun](https://bun.sh/) >= 1.2.5
- Gitea instance with API access
- OpenAI API key
### Installation
### 2) Install
```bash
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
cp .env.example .env
```
### Configuration
Edit `.env` with your settings:
If lifecycle scripts are disabled in your environment, run:
```bash
# Gitea
GITEA_API_URL=https://your-gitea-instance.com/api/v1
GITEA_ACCESS_TOKEN=your_gitea_token
# OpenAI
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
# Security
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
# Admin Dashboard
ADMIN_PASSWORD=your_admin_password
bun run bootstrap
```
See [Configuration Reference](#configuration-reference) for all options.
### Running
### 3) Minimal `.env`
```bash
bun run dev # Development mode
bun run start # Production mode
PORT=5174
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
```
### Setting Up Webhooks
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
**Option 1: Admin Dashboard (Recommended)**
1. Access `http://your-server:3000`
2. Log in with `ADMIN_PASSWORD`
3. Click "Enable" on repositories to auto-configure webhooks
**Option 2: Manual Configuration**
In Gitea repository settings, add a webhook:
- **URL**: `http://your-server:3000/webhook/gitea`
- **Content Type**: `application/json`
- **Secret**: Same as `WEBHOOK_SECRET`
- **Events**: "Pull Request" and "Status"
## Configuration Reference
### Core Settings
| Variable | Description | Default |
|----------|-------------|---------|
| `GITEA_API_URL` | Gitea API endpoint | Required |
| `GITEA_ACCESS_TOKEN` | Token for code review (read + comment permissions) | Required |
| `GITEA_ADMIN_TOKEN` | Token for webhook management (optional) | - |
| `OPENAI_BASE_URL` | OpenAI API base URL | `https://api.openai.com/v1` |
| `OPENAI_API_KEY` | OpenAI API key | Required |
| `OPENAI_MODEL` | Model to use | `gpt-4o-mini` |
| `PORT` | Server port | `3000` |
| `WEBHOOK_SECRET` | Webhook signature secret | Required |
### Custom Prompts
| Variable | Description |
|----------|-------------|
| `CUSTOM_SUMMARY_PROMPT` | Override the default summary review prompt |
| `CUSTOM_LINE_COMMENT_PROMPT` | Override the default line comment prompt |
### Admin Dashboard
| Variable | Description | Default |
|----------|-------------|---------|
| `ADMIN_PASSWORD` | Dashboard login password | `password` |
| `JWT_SECRET` | JWT signing secret | Auto-generated |
### Feishu Integration
| Variable | Description |
|----------|-------------|
| `FEISHU_WEBHOOK_URL` | Feishu bot webhook URL |
| `FEISHU_WEBHOOK_SECRET` | Feishu webhook secret (optional) |
### Agent Review Engine
Enable with `REVIEW_ENGINE=agent` for advanced multi-agent reviews:
| Variable | Description | Default |
|----------|-------------|---------|
| `REVIEW_ENGINE` | Engine mode (`legacy` or `agent`) | `legacy` |
| `REVIEW_WORKDIR` | Working directory for repo clones | `/tmp/gitea-assistant` |
| `REVIEW_MODEL_PLANNER` | Planner model | `gpt-4o-mini` |
| `REVIEW_MODEL_SPECIALIST` | Specialist model | `gpt-4o-mini` |
| `REVIEW_MODEL_JUDGE` | Judge model | `gpt-4o-mini` |
| `REVIEW_MAX_PARALLEL_RUNS` | Max concurrent tasks | `2` |
| `REVIEW_MAX_FILES_PER_RUN` | Max files per review | `200` |
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | Min confidence for auto-publish | `0.8` |
| `REVIEW_ENABLE_HUMAN_GATE` | Enable human approval | `true` |
### Memory & Learning (Experimental)
| Variable | Description | Default |
|----------|-------------|---------|
| `QDRANT_URL` | Qdrant vector database URL | - |
| `ENABLE_MEMORY` | Enable memory system | `false` |
| `ENABLE_REFLECTION` | Enable self-critique | `false` |
| `ENABLE_DEBATE` | Enable multi-agent debate | `false` |
## Deployment
### Docker
### 4) Run
```bash
docker build -t gitea-assistant .
docker run -d -p 3000:3000 --env-file .env gitea-assistant
bun run dev
# or
bun run start
```
### Docker Compose
### 5) Configure in Admin UI
```bash
docker-compose up -d
```
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
- Configure Gitea API + tokens
- Configure webhook secret
- Configure LLM providers/models
- Configure review engine and policy
### 6) Add webhook in Gitea
- URL: `http://your-server:5174/webhook/gitea`
- Content-Type: `application/json`
- Secret: same as dashboard webhook secret
- Events: Pull Request + Status
## Progressive disclosure: detailed docs
- [Documentation index](./docs/README.md)
- [Getting started details](./docs/getting-started.md)
- [Configuration reference](./docs/configuration.md)
- [Review engines](./docs/review-engines.md)
- [Deployment (Docker / Compose / Kubernetes)](./docs/deployment.md)
## License

237
bun.lock
View File

@@ -1,16 +1,20 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ai-review",
"name": "gitea-assistant",
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@google/genai": "^1.43.0",
"@hono/zod-validator": "^0.4.3",
"@qdrant/js-client-rest": "^1.16.2",
"axios": "^1.8.3",
"dotenv": "^16.4.7",
"hono": "^4.11.9",
"lodash-es": "^4.17.21",
"openai": "^4.87.3",
"pino": "^10.3.1",
"tokenlens": "^1.3.1",
"zod": "^3.25.1",
"zod-to-json-schema": "^3.25.1",
},
@@ -19,19 +23,25 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.6",
"@types/bun": "^1.3.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"semantic-release": "^24.2.9",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
@@ -52,8 +62,12 @@
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
"@google/genai": ["@google/genai@1.44.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
@@ -76,15 +90,37 @@
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
"@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="],
"@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.2", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA=="],
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.16.2", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.0.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-Zm4wEZURrZ24a+Hmm4l1QQYjiz975Ep3vF0yzWR7ICGcxittNz47YK2iBOk8kb8qseCu8pg7WmO1HOIsO8alvw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
@@ -108,16 +144,28 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
"@tokenlens/helpers": ["@tokenlens/helpers@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0" } }, "sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ=="],
"@tokenlens/models": ["@tokenlens/models@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -142,14 +190,28 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.8.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
@@ -178,9 +240,9 @@
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
"conventional-changelog-angular": ["conventional-changelog-angular@8.2.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-4YB1zEXqB17oBI8yRsAs1T+ZhbdsOgJqkl6Trz+GXt/eKf1e4jnA0oW+sOd9BEENzEViuNW0DNoFFjSf3CeC5Q=="],
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA=="],
"conventional-changelog-writer": ["conventional-changelog-writer@8.3.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-l5hDOHjcTUVtnZJapoqXMCJ3IbyF6oV/vnxKL13AHulFH7mDp4PMJARxI7LWzob6UDDvhxIUWGTNUPW84JabQg=="],
"conventional-changelog-writer": ["conventional-changelog-writer@8.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g=="],
"conventional-commits-filter": ["conventional-commits-filter@5.0.0", "", {}, "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q=="],
@@ -196,6 +258,8 @@
"crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
@@ -206,12 +270,16 @@
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="],
@@ -240,10 +308,14 @@
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -254,22 +326,30 @@
"find-versions": ["find-versions@6.0.0", "", { "dependencies": { "semver-regex": "^4.0.5", "super-regex": "^1.0.0" } }, "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"from2": ["from2@2.3.0", "", { "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" } }, "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g=="],
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -280,6 +360,12 @@
"git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -296,7 +382,7 @@
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
"hook-std": ["hook-std@4.0.0", "", {}, "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ=="],
@@ -310,6 +396,8 @@
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-from-esm": ["import-from-esm@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" } }, "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g=="],
@@ -346,20 +434,30 @@
"issue-parser": ["issue-parser@7.0.1", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"java-properties": ["java-properties@1.0.2", "", {}, "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-with-bigint": ["json-with-bigint@3.5.7", "", {}, "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="],
@@ -380,6 +478,8 @@
"lodash.uniqby": ["lodash.uniqby@4.7.0", "", {}, "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="],
@@ -404,8 +504,12 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@@ -424,15 +528,17 @@
"normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="],
"npm": ["npm@10.9.4", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.1", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.2", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.4.1", "ci-info": "^4.2.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.4.5", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.1", "libnpmexec": "^9.0.1", "libnpmfund": "^6.0.1", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.1", "libnpmpublish": "^10.0.1", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.5", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.2.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.0", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.1", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.3", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.1", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA=="],
"npm": ["npm@10.9.5", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^8.0.2", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", "@npmcli/package-json": "^6.2.0", "@npmcli/promise-spawn": "^8.0.3", "@npmcli/redact": "^3.2.2", "@npmcli/run-script": "^9.1.0", "@sigstore/tuf": "^3.1.1", "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", "libnpmdiff": "^7.0.2", "libnpmexec": "^9.0.2", "libnpmfund": "^6.0.2", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", "libnpmpack": "^8.0.2", "libnpmpublish": "^10.0.2", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", "minimatch": "^9.0.9", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^11.5.0", "nopt": "^8.1.0", "normalize-package-data": "^7.0.1", "npm-audit-report": "^6.0.0", "npm-install-checks": "^7.1.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", "p-map": "^7.0.4", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", "read": "^4.1.0", "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", "tar": "^6.2.1", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", "validate-npm-package-name": "^6.0.2", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-tFABtwt8S5KDs6DKs4p8uQ+u+8Hpx4ReD6bmkrPzPI0hsYkRWIkY/esz6ZtHyHvqVOltTB9DM/812Lx++SIXRw=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"openai": ["openai@4.87.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
"p-each-series": ["p-each-series@3.0.0", "", {}, "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw=="],
@@ -450,10 +556,14 @@
"p-reduce": ["p-reduce@2.1.0", "", {}, "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
"p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
@@ -468,6 +578,8 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -476,16 +588,28 @@
"pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
@@ -494,15 +618,23 @@
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="],
@@ -524,6 +656,8 @@
"skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="],
@@ -542,10 +676,14 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
@@ -566,6 +704,8 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
"through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
@@ -574,23 +714,26 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="],
@@ -622,6 +765,10 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -636,6 +783,12 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="],
@@ -652,8 +805,6 @@
"@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="],
"@types/node-fetch/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
@@ -662,6 +813,14 @@
"env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
@@ -678,7 +837,7 @@
"npm/@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
"npm/@npmcli/arborist": ["@npmcli/arborist@8.0.1", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/map-workspaces": "^4.0.1", "@npmcli/metavuln-calculator": "^8.0.0", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.1", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", "@npmcli/run-script": "^9.0.1", "bin-links": "^5.0.0", "cacache": "^19.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", "npm-package-arg": "^12.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.1", "pacote": "^19.0.0", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", "walk-up-path": "^3.0.1" }, "bundled": true, "bin": { "arborist": "bin/index.js" } }, "sha512-ZyJWuvP+SdT7JmHkmtGyElm/MkQZP/i4boJXut6HDgx1tmJc/JZ9OwahRuKD+IyowJcLyB/bbaXtYh+RoTCUuw=="],
"npm/@npmcli/arborist": ["@npmcli/arborist@8.0.2", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/map-workspaces": "^4.0.1", "@npmcli/metavuln-calculator": "^8.0.0", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.1", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", "@npmcli/run-script": "^9.0.1", "bin-links": "^5.0.0", "cacache": "^19.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", "npm-package-arg": "^12.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.1", "pacote": "^19.0.0", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "promise-retry": "^2.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", "walk-up-path": "^3.0.1" }, "bundled": true, "bin": { "arborist": "bin/index.js" } }, "sha512-BN7B/7QJrbRMITJujeYWTx1PEdUwTuJykfEdm+AAljLirAJ7j6Afh9lsHvbkvGbxaN5SqZhwIET5oRg+9osJJA=="],
"npm/@npmcli/config": ["@npmcli/config@9.0.0", "", { "dependencies": { "@npmcli/map-workspaces": "^4.0.1", "@npmcli/package-json": "^6.0.1", "ci-info": "^4.0.0", "ini": "^5.0.0", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-P5Vi16Y+c8E0prGIzX112ug7XxqfaPFUVW/oXAV+2VsxplKZEnJozqZ0xnK8V8w/SEsBf+TXhUihrEIAU4CA5Q=="],
@@ -838,19 +997,19 @@
"npm/libnpmaccess": ["libnpmaccess@9.0.0", "", { "dependencies": { "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-mTCFoxyevNgXRrvgdOhghKJnCWByBc9yp7zX4u9RBsmZjwOYdUDEBfL5DdgD1/8gahsYnauqIWFbq0iK6tO6CQ=="],
"npm/libnpmdiff": ["libnpmdiff@7.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^2.3.0", "diff": "^5.1.0", "minimatch": "^9.0.4", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "tar": "^6.2.1" }, "bundled": true }, "sha512-CPcLUr23hLwiil/nAlnMQ/eWSTXPPaX+Qe31di8JvcV2ELbbBueucZHBaXlXruUch6zIlSY6c7JCGNAqKN7yaQ=="],
"npm/libnpmdiff": ["libnpmdiff@7.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^2.3.0", "diff": "^5.1.0", "minimatch": "^9.0.4", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "tar": "^6.2.1" }, "bundled": true }, "sha512-dAJThCk2WvMhrrJlfCQidVMcs8TOQWPCdP73e0/uTGRqulAxV2i3OqVc6j4ja8c/owwF4GrWo+eip/UMEWGJsQ=="],
"npm/libnpmexec": ["libnpmexec@9.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", "ci-info": "^4.0.0", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "proc-log": "^5.0.0", "read": "^4.0.0", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-+SI/x9p0KUkgJdW9L0nDNqtjsFRY3yA5kQKdtGYNMXX4iP/MXQjuXF8MaUAweuV6Awm8plxqn8xCPs2TelZEUg=="],
"npm/libnpmexec": ["libnpmexec@9.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/run-script": "^9.0.1", "ci-info": "^4.0.0", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", "proc-log": "^5.0.0", "read": "^4.0.0", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "walk-up-path": "^3.0.1" }, "bundled": true }, "sha512-9C6mZlJKYkmIJlfnIx2Qd65lDkOkiGonMRejBhOKbetrTpRFnUuEg7utQh3basaThR8wTphSDKWH1t48PduCjQ=="],
"npm/libnpmfund": ["libnpmfund@6.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1" }, "bundled": true }, "sha512-UBbHY9yhhZVffbBpFJq+TsR2KhhEqpQ2mpsIJa6pt0PPQaZ2zgOjvGUYEjURYIGwg2wL1vfQFPeAtmN5w6i3Gg=="],
"npm/libnpmfund": ["libnpmfund@6.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2" }, "bundled": true }, "sha512-uwbQ7qfXQVDvd1gOVPU45yUhG/H5i/JCejewOcje2k2SleR/KxQG3ntHmDGx8jazfpFH4upHiNy7QxnzA0+kLA=="],
"npm/libnpmhook": ["libnpmhook@11.0.0", "", { "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-Xc18rD9NFbRwZbYCQ+UCF5imPsiHSyuQA8RaCA2KmOUo8q4kmBX4JjGWzmZnxZCT8s6vwzmY1BvHNqBGdg9oBQ=="],
"npm/libnpmorg": ["libnpmorg@7.0.0", "", { "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-DcTodX31gDEiFrlIHurBQiBlBO6Var2KCqMVCk+HqZhfQXqUfhKGmFOp0UHr6HR1lkTVM0MzXOOYtUObk0r6Dg=="],
"npm/libnpmpack": ["libnpmpack@8.0.1", "", { "dependencies": { "@npmcli/arborist": "^8.0.1", "@npmcli/run-script": "^9.0.1", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0" }, "bundled": true }, "sha512-E53w3QcldAXg5cG9NpXZcsgNiLw5AEtu7ufGJk6+dxudD0/U5Y6vHIws+CJiI76I9rAidXasKmmS2mwiYDncBw=="],
"npm/libnpmpack": ["libnpmpack@8.0.2", "", { "dependencies": { "@npmcli/arborist": "^8.0.2", "@npmcli/run-script": "^9.0.1", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0" }, "bundled": true }, "sha512-2lYa08FlZMcMkxPSOEkAqj74Id+LvEUfSKfyYZi3BNjF7TTk8QQTh7oFsAGqUClbXiQ8HJiGYaX2OhYZGU92hQ=="],
"npm/libnpmpublish": ["libnpmpublish@10.0.1", "", { "dependencies": { "ci-info": "^4.0.0", "normalize-package-data": "^7.0.0", "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1", "proc-log": "^5.0.0", "semver": "^7.3.7", "sigstore": "^3.0.0", "ssri": "^12.0.0" }, "bundled": true }, "sha512-xNa1DQs9a8dZetNRV0ky686MNzv1MTqB3szgOlRR3Fr24x1gWRu7aB9OpLZsml0YekmtppgHBkyZ+8QZlzmEyw=="],
"npm/libnpmpublish": ["libnpmpublish@10.0.2", "", { "dependencies": { "ci-info": "^4.0.0", "normalize-package-data": "^7.0.0", "npm-package-arg": "^12.0.0", "npm-registry-fetch": "^18.0.1", "proc-log": "^5.0.0", "semver": "^7.3.7", "sigstore": "^3.0.0", "ssri": "^12.0.0" }, "bundled": true }, "sha512-Q+PlGO6vOZDlZ6jKPDqDLYbARfV5OBusmJZj9GPbNUiys8OK6/yrwJ8ty8ibbc4GkMspqgOMdJ/1dcJwhtpkDg=="],
"npm/libnpmsearch": ["libnpmsearch@8.0.0", "", { "dependencies": { "npm-registry-fetch": "^18.0.1" }, "bundled": true }, "sha512-W8FWB78RS3Nkl1gPSHOlF024qQvcoU/e3m9BGDuBfVZGfL4MJ91GXXb04w3zJCGOW9dRQUyWVEqupFjCrgltDg=="],
@@ -1022,12 +1181,16 @@
"npm/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openai/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
"pino-abstract-transport/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="],
"semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="],
@@ -1040,8 +1203,12 @@
"signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
@@ -1050,6 +1217,10 @@
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@semantic-release/github/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="],
@@ -1072,8 +1243,6 @@
"@semantic-release/npm/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
@@ -1098,7 +1267,7 @@
"npm/@npmcli/metavuln-calculator/pacote": ["pacote@20.0.0", "", { "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/package-json": "^6.0.0", "@npmcli/promise-spawn": "^8.0.0", "@npmcli/run-script": "^9.0.0", "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^12.0.0", "npm-packlist": "^9.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", "tar": "^6.1.11" }, "bin": { "pacote": "bin/index.js" } }, "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A=="],
"npm/cacache/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"npm/cacache/tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="],
"npm/cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1108,7 +1277,7 @@
"npm/minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"npm/node-gyp/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"npm/node-gyp/tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="],
"npm/spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="],

2
bunfig.toml Normal file
View File

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

View File

@@ -46,22 +46,14 @@ services:
- NODE_ENV=production
- GITEA_API_URL=http://gitea:3000/api/v1
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-test_key}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
- FEISHU_WEBHOOK_URL=http://localhost:9999/noop
- PORT=3000
- E2E_MOCK_LLM=1
- PORT=5174
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
- WEBHOOK_SECRET=e2e-test-secret
- REVIEW_ENGINE=agent
- REVIEW_WORKDIR=/tmp/e2e-review
- REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.5
- REVIEW_ENABLE_HUMAN_GATE=false
- REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
- REVIEW_COMMAND_TIMEOUT_MS=30000
ports:
- "3334:3000"
- "3334:5174"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
interval: 5s
timeout: 3s
retries: 10

View File

@@ -8,15 +8,17 @@ services:
container_name: gitea-assistant
ports:
- "3000:3000"
- "5174:5174"
volumes:
- ./config-overrides.json:/app/config-overrides.json
- assistant_data:/app/data
env_file:
- .env
environment:
LOG_LEVEL: error
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
interval: 30s
timeout: 5s
retries: 3
@@ -32,3 +34,7 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
assistant_data:
driver: local

20
docs/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Documentation
This project keeps the root `README.md` concise and moves implementation/deployment details here.
## Start here
- [Getting started](./getting-started.md)
- [Configuration reference](./configuration.md)
- [Review engines](./review-engines.md)
- [Deployment](./deployment.md)
- [Screenshot gallery](./screenshots.md)
## Architecture & design
- [Notification service refactoring](./design/notification-service-refactoring.md)
- [UI theme language](./design/ui-theme-language.md)
## Language
- 中文: [README.zh-CN.md](./README.zh-CN.md)

View File

@@ -1,180 +1,24 @@
# Gitea AI Assistant
# 文档中心
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档。
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,使用 OpenAI 提供智能代码质量分析,支持总体评论和行级反馈。
## 快速导航
**[English Documentation](../README.md)**
- [快速开始](./getting-started.zh-CN.md)
- [配置参考](./configuration.zh-CN.md)
- [审查引擎](./review-engines.zh-CN.md)
- [部署指南](./deployment.zh-CN.md)
- [截图集](./screenshots.zh-CN.md)
## 功能特点
## 架构与设计
- 🤖 **AI 代码审查** - 使用 OpenAI 模型自动审查 PR 和提交
- 📝 **行级评论** - 针对具体代码变更的精确反馈
- 🔄 **双引擎模式** - Legacy简单或 Agent多代理审查模式
- 🔔 **飞书通知** - PR 事件通知集成
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和配置的 Web 界面
- 🔐 **安全验证** - HMAC-SHA256 签名验证
- [通知服务重构设计](./design/notification-service-refactoring.md)
- [UI 主题语言设计](./design/ui-theme-language.md)
## 架构设计
## 产品截图
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ OpenAI API │
│ (Webhooks) │ │ (Hono + Bun) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌──────────────────┐
│ 管理后台 │
│ (React SPA) │
└──────────────────┘
```
![Gitea AI Assistant 管理后台(仓库管理页)](./assets/page-repos.png)
### 审查引擎对比
## 语言切换
| 引擎 | 描述 | 适用场景 |
|------|------|----------|
| `legacy` | 单次 AI 审查,生成总结和行级评论 | 简单、快速的审查 |
| `agent` | 多代理编排,支持专家、反思和辩论 | 深度、全面的分析 |
## 快速开始
### 环境要求
- [Bun](https://bun.sh/) >= 1.2.5
- 可访问的 Gitea 实例
- OpenAI API 密钥
### 安装步骤
```bash
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
cp .env.example .env
```
### 配置说明
编辑 `.env` 文件:
```bash
# Gitea
GITEA_API_URL=https://your-gitea-instance.com/api/v1
GITEA_ACCESS_TOKEN=your_gitea_token
# OpenAI
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
# 安全
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
# 管理后台
ADMIN_PASSWORD=your_admin_password
```
完整配置项请参阅 [配置参考](#配置参考)。
### 启动服务
```bash
bun run dev # 开发模式
bun run start # 生产模式
```
### 配置 Webhook
**方式一:管理后台(推荐)**
1. 在浏览器中访问 `http://your-server:3000`
2. 使用 `ADMIN_PASSWORD` 登录
3. 点击仓库对应的「启用」按钮自动配置 Webhook
**方式二:手动配置**
在 Gitea 仓库设置中添加 Webhook
- **URL**: `http://your-server:3000/webhook/gitea`
- **内容类型**: `application/json`
- **密钥**: 与 `WEBHOOK_SECRET` 相同
- **触发事件**: 「Pull Request」和「Status」
## 配置参考
### 核心配置
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `GITEA_API_URL` | Gitea API 地址 | 必填 |
| `GITEA_ACCESS_TOKEN` | 代码审查令牌(需要读取和评论权限) | 必填 |
| `GITEA_ADMIN_TOKEN` | Webhook 管理令牌(可选) | - |
| `OPENAI_BASE_URL` | OpenAI API 基础地址 | `https://api.openai.com/v1` |
| `OPENAI_API_KEY` | OpenAI API 密钥 | 必填 |
| `OPENAI_MODEL` | 使用的模型 | `gpt-4o-mini` |
| `PORT` | 服务端口 | `3000` |
| `WEBHOOK_SECRET` | Webhook 签名验证密钥 | 必填 |
### 自定义提示词
| 变量 | 描述 |
|------|------|
| `CUSTOM_SUMMARY_PROMPT` | 自定义总结审查提示词 |
| `CUSTOM_LINE_COMMENT_PROMPT` | 自定义行级评论提示词 |
### 管理后台
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `ADMIN_PASSWORD` | 后台登录密码 | `password` |
| `JWT_SECRET` | JWT 签名密钥 | 自动生成 |
### 飞书集成
| 变量 | 描述 |
|------|------|
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook 地址 |
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 密钥(可选) |
### Agent 审查引擎
设置 `REVIEW_ENGINE=agent` 启用多代理审查:
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `REVIEW_ENGINE` | 引擎模式(`legacy``agent` | `legacy` |
| `REVIEW_WORKDIR` | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
| `REVIEW_MODEL_PLANNER` | 规划模型 | `gpt-4o-mini` |
| `REVIEW_MODEL_SPECIALIST` | 专家模型 | `gpt-4o-mini` |
| `REVIEW_MODEL_JUDGE` | 判断模型 | `gpt-4o-mini` |
| `REVIEW_MAX_PARALLEL_RUNS` | 最大并发任务数 | `2` |
| `REVIEW_MAX_FILES_PER_RUN` | 单次审查最大文件数 | `200` |
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | 自动发布最小置信度 | `0.8` |
| `REVIEW_ENABLE_HUMAN_GATE` | 启用人工审批 | `true` |
### 记忆与学习(实验性)
| 变量 | 描述 | 默认值 |
|------|------|--------|
| `QDRANT_URL` | Qdrant 向量数据库地址 | - |
| `ENABLE_MEMORY` | 启用记忆系统 | `false` |
| `ENABLE_REFLECTION` | 启用自我批评 | `false` |
| `ENABLE_DEBATE` | 启用多代理辩论 | `false` |
## 部署指南
### Docker
```bash
docker build -t gitea-assistant .
docker run -d -p 3000:3000 --env-file .env gitea-assistant
```
### Docker Compose
```bash
docker-compose up -d
```
## 许可证
MIT 许可证
- English: [README.md](./README.md)

0
docs/assets/.gitkeep Normal file
View File

BIN
docs/assets/page-config.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
docs/assets/page-repos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

85
docs/configuration.md Normal file
View File

@@ -0,0 +1,85 @@
# Configuration Reference
## Configuration model
This project uses a DB-first runtime configuration model:
- `.env` contains only infrastructure-level bootstrap values.
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
## Environment variables (minimal)
| Variable | Required | Description | Default |
|---|---|---|---|
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
| `PORT` | No | Service port | `5174` |
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
Generate key:
```bash
openssl rand -hex 32
```
## First boot defaults
When database is empty:
- `JWT_SECRET` auto-generated
- `WEBHOOK_SECRET` auto-generated
- `ADMIN_PASSWORD` defaults to `password`
Change `ADMIN_PASSWORD` immediately after first login.
## Runtime groups in Admin UI
## 1) Gitea
- API URL
- Access token
- Admin token (optional)
## 2) Security
- Webhook secret (HMAC-SHA256 verification)
- Admin password
- JWT secret
## 3) LLM
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
- Agent runtime models:
- `AGENT_MAIN_MODEL`: The main model name used by the agent runtime when no specific model is configured. Default is `gpt-4.1`.
- `AGENT_DEFAULT_SUBAGENT_MODEL`: The default model name used by subagents when no specific model is declared in their definition or overridden during spawn. Default is `gpt-4.1-mini`.
## 4) Notification
- Feishu webhook and optional secret
- WeCom (企业微信) webhook
## 5) Review
- Engine mode: `agent` or `codex`
- Triage size classification and routing hints
- Size thresholds (`small`/`medium`/`large`)
- Execution modes (`skip`/`light`/`full`)
- Token budgets and concurrency limits
> Size and mode are different layers:
>
> - `small/medium/large`: change-size classification
> - `skip/light/full`: review execution depth
## Agent Definitions
Project agent definitions are stored as Markdown files with frontmatter in the repository:
- Path: `.gitea-assistant/agents/*.md`
These files define the system prompts, metadata, and execution parameters for each agent.
## Tool Permissions
Tool permissions are controlled directly within each agent's definition file:
- `tools`: An allow-list of tool names that the agent is permitted to call. An empty list grants no tools.
- `disallowedTools`: A deny-list of tool names that the agent is explicitly forbidden from calling. This takes precedence over the allow-list.

View File

@@ -0,0 +1,85 @@
# 配置参考
## 配置模型
项目采用 DB-first 运行时配置模型:
- `.env` 仅用于基础设施级引导参数
- 运行时配置Gitea、Provider、密钥、审查策略、通知由管理后台维护并持久化到 SQLite
## 环境变量(最小集)
| 变量 | 必填 | 说明 | 默认值 |
|---|---|---|---|
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥AES-256-GCM64 位十六进制) | - |
| `PORT` | 否 | 服务端口 | `5174` |
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
生成密钥:
```bash
openssl rand -hex 32
```
## 首次启动默认值
当数据库为空时:
- `JWT_SECRET` 自动生成
- `WEBHOOK_SECRET` 自动生成
- `ADMIN_PASSWORD` 默认 `password`
首次登录后请立即修改管理员密码。
## 管理后台配置分组
## 1) Gitea
- API URL
- Access Token
- Admin Token可选
## 2) 安全
- Webhook SecretHMAC-SHA256 验签)
- Admin Password
- JWT Secret
## 3) LLM
- ProviderOpenAI Compatible / OpenAI Responses / Anthropic / Gemini
- Agent 运行时模型:
- `AGENT_MAIN_MODEL`在没有更具体模型配置时Agent 运行时使用的主模型名称。默认值为 `gpt-4.1`
- `AGENT_DEFAULT_SUBAGENT_MODEL`当子代理Subagent未声明模型且 spawn 未覆盖时,使用的默认模型名称。默认值为 `gpt-4.1-mini`
## 4) 通知
- Feishu Webhook 与可选签名密钥
- WeCom企业微信Webhook
## 5) 审查
- 引擎模式:`agent` / `codex`
- Triage 规模分类与路由提示
- 规模阈值(`small`/`medium`/`large`
- 执行模式(`skip`/`light`/`full`
- Token 预算与并发限制
> 规模与模式是两个层次:
>
> - `small/medium/large`:变更规模分类
> - `skip/light/full`:审查执行深度
## Agent 定义
项目的 Agent 定义以带有 Frontmatter 的 Markdown 文件形式存储在仓库中:
- 路径:`.gitea-assistant/agents/*.md`
这些文件定义了每个 Agent 的系统提示词、元数据和执行参数。
## 工具权限
工具权限直接在每个 Agent 的定义文件中进行控制:
- `tools`:允许该 Agent 调用的工具名称白名单。如果列表为空,则不授予任何工具权限。
- `disallowedTools`:显式禁止该 Agent 调用的工具名称黑名单。黑名单的优先级高于白名单。

58
docs/deployment.md Normal file
View File

@@ -0,0 +1,58 @@
# Deployment
## Docker
```bash
docker build -t gitea-assistant .
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
```
## Docker Compose
```bash
docker compose up -d
```
`docker-compose.yml` includes `gitea-assistant`.
Production default in compose sets `LOG_LEVEL=error`.
## Kubernetes
Kubernetes manifests are in `k8s/`.
The default ConfigMap sets `LOG_LEVEL=error` for production.
### 1) Create namespace and encryption secret
```bash
kubectl apply -f k8s/namespace.yaml
ENCRYPTION_KEY=$(openssl rand -hex 32)
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
```
### 2) Deploy
```bash
kubectl apply -k k8s/
```
Or apply individually:
```bash
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/gitea-assistant.yaml
```
### 3) Verify
```bash
kubectl -n gitea-assistant get pods
kubectl -n gitea-assistant get svc
```
### 4) Expose service (optional)
```bash
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
```

58
docs/deployment.zh-CN.md Normal file
View File

@@ -0,0 +1,58 @@
# 部署指南
## Docker
```bash
docker build -t gitea-assistant .
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
```
## Docker Compose
```bash
docker compose up -d
```
`docker-compose.yml` 默认包含 `gitea-assistant`
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`
## Kubernetes
Kubernetes 清单位于 `k8s/` 目录。
默认 ConfigMap 已将生产日志级别设置为 `LOG_LEVEL=error`
### 1) 创建命名空间与加密密钥
```bash
kubectl apply -f k8s/namespace.yaml
ENCRYPTION_KEY=$(openssl rand -hex 32)
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
```
### 2) 部署
```bash
kubectl apply -k k8s/
```
或逐个应用:
```bash
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/gitea-assistant.yaml
```
### 3) 验证
```bash
kubectl -n gitea-assistant get pods
kubectl -n gitea-assistant get svc
```
### 4) 对外暴露(可选)
```bash
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
```

View File

@@ -0,0 +1,617 @@
# 通知服务抽象化重构方案
## 1. 概述
### 1.1 背景
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台代码高度耦合飞书特定的API实现。随着业务需求扩展需要支持企业微信(WeCom)等其他通知渠道。
### 1.2 目标
- 抽象通用通知服务接口,支持多平台扩展
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
- 统一通知调用入口,避免平台耦合与重复发送
- 清晰的代码结构便于后续添加新平台如Slack、钉钉等
### 1.3 非目标
- 不修改通知的业务触发逻辑
- 不改变现有的Gitea Webhook处理流程
- 不引入外部通知服务SDK依赖保持轻量
---
## 2. 现有架构分析
### 2.1 重构前实现(已下线)
```
src/
├── services/feishu.ts # 飞书服务实现156行
├── controllers/review.ts # 通知调用点
├── config/config-schema.ts # 配置定义
└── config/config-manager.ts # 配置管理
```
### 2.2 关键代码特征
- **强耦合**`review.ts` 直接调用 `feishuService.sendXXXNotification()`
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
- **签名逻辑**HMAC-SHA256(timestamp+"\n"+secret)
- **配置单一**:仅支持一组飞书配置
### 2.3 通知场景
| 场景 | 方法名 | 触发条件 |
|------|--------|----------|
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
---
## 3. 目标架构设计
### 3.1 架构模式
采用**策略模式(Strategy)** + **工厂模式(Factory)**
```
┌─────────────────────────────────────────────────────────────┐
│ Notification Service Layer │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ INotification │ │ INotification │ │ INotification│ │
│ │ Service │ │ Service │ │ Service │ │
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
│ │ Feishu │ │ WeCom │ │ Slack │ │
│ │Service │ │Service │ │ Service │ │
│ └───────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────┴─────────┐
│ NotificationFactory│
└─────────┬─────────┘
┌─────────┴─────────┐
│ NotificationManager│ ← 统一入口,支持多服务
└───────────────────┘
```
### 3.2 核心接口设计
#### 3.2.1 类型定义
```typescript
// types.ts
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
export interface NotificationContext {
// PR相关
prTitle?: string;
prUrl?: string;
prNumber?: number;
// Issue相关
issueTitle?: string;
issueUrl?: string;
issueNumber?: number;
// 用户相关
actor?: string;
assignees?: string[];
reviewers?: string[];
creator?: string;
// 仓库相关
repository?: string;
owner?: string;
// 时间戳
timestamp?: Date;
}
export interface NotificationMessage {
type: 'text' | 'markdown';
title?: string;
content: string;
atUsers?: string[];
url?: string;
}
```
#### 3.2.2 服务接口
```typescript
// INotificationService
export interface INotificationService {
readonly provider: NotificationProvider;
isEnabled(): boolean;
sendMessage(message: NotificationMessage): Promise<void>;
// 场景特定方法
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
}
```
### 3.3 平台差异对照
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|------|--------------|-----------------|-------|
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
| **内容字段** | `content.text` | `text.content` | `text` |
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
---
## 4. 详细实现方案
### 4.1 目录结构
```
src/
├── services/
│ ├── notification/
│ │ ├── index.ts # 导出入口
│ │ ├── types.ts # 类型定义
│ │ ├── base-notification-service.ts # 抽象基类
│ │ ├── notification-factory.ts # 工厂
│ │ ├── notification-manager.ts # 管理器
│ │ └── providers/
│ │ ├── feishu-notification-service.ts
│ │ └── wecom-notification-service.ts
│ └── notification-manager.ts # 运行时通知管理器入口
```
### 4.2 基类实现
```typescript
// base-notification-service.ts
export abstract class BaseNotificationService implements INotificationService {
abstract readonly provider: NotificationProvider;
constructor(protected config: NotificationServiceConfig) {}
isEnabled(): boolean {
return this.config.enabled && !!this.config.webhookUrl;
}
abstract sendMessage(message: NotificationMessage): Promise<void>;
// 通用模板方法
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
const message = this.buildIssueCreatedMessage(context);
await this.sendMessage(message);
}
// 子类实现消息构建
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
// ... 其他方法类似
}
```
### 4.3 飞书实现要点
```typescript
// feishu-notification-service.ts
export class FeishuNotificationService extends BaseNotificationService {
readonly provider = 'feishu' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
const payload: any = {
msg_type: 'text',
content: {
text: message.content,
},
};
// 添加签名
if (this.config.webhookSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
payload.timestamp = timestamp;
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
}
const response = await fetch(this.config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Feishu notification failed: ${response.statusText}`);
}
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
};
}
private generateSign(timestamp: string, secret: string): string {
const stringToSign = `${timestamp}\n${secret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
return hmac.digest('base64');
}
}
```
### 4.4 企业微信实现要点
```typescript
// wecom-notification-service.ts
export class WeComNotificationService extends BaseNotificationService {
readonly provider = 'wecom' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
const payload: any = {
msgtype: 'text',
text: {
content: message.content,
},
};
// 企业微信使用 mentioned_list
if (message.atUsers?.length) {
payload.text.mentioned_list = message.atUsers.map(u =>
u.toLowerCase() === 'all' ? '@all' : u
);
}
const response = await fetch(this.config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`WeCom notification failed: ${response.statusText}`);
}
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
};
}
}
```
### 4.5 管理器实现
```typescript
// notification-manager.ts
export class NotificationManager {
private services: INotificationService[] = [];
constructor(configs: NotificationServiceConfig[]) {
this.services = configs
.filter(c => c.enabled && c.webhookUrl)
.map(c => NotificationFactory.createService(c));
}
// 广播到所有服务
async broadcast(
operation: (service: INotificationService) => Promise<void>
): Promise<void> {
const results = await Promise.allSettled(
this.services.map(async service => {
try {
await operation(service);
} catch (error) {
logger.error(`${service.provider} notification failed:`, error);
throw error; // 重新抛出以便Promise.allSettled捕获
}
})
);
// 记录失败统计
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
}
}
// 便捷方法
async notifyIssueCreated(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueCreatedNotification(context));
}
async notifyIssueClosed(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueClosedNotification(context));
}
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueAssignedNotification(context));
}
async notifyPrCreated(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendPrCreatedNotification(context));
}
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
}
}
```
---
## 5. 配置改造
### 5.1 新增配置字段
```typescript
// config-schema.ts
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
// ... 保留原有 ...
// 飞书配置(改造为可独立启用)
{
envKey: 'FEISHU_ENABLED',
group: 'notification',
label: '启用飞书通知',
description: '是否启用飞书通知',
type: 'boolean',
sensitive: false,
defaultValue: true,
},
{
envKey: 'FEISHU_WEBHOOK_URL',
group: 'notification',
label: '飞书 Webhook 地址',
description: '飞书机器人 Webhook URL',
type: 'url',
sensitive: false,
},
{
envKey: 'FEISHU_WEBHOOK_SECRET',
group: 'notification',
label: '飞书 Webhook 密钥',
description: '飞书 Webhook 签名密钥(可选)',
type: 'string',
sensitive: true,
},
// 企业微信配置(新增)
{
envKey: 'WECOM_ENABLED',
group: 'notification',
label: '启用企业微信通知',
description: '是否启用企业微信通知',
type: 'boolean',
sensitive: false,
defaultValue: false,
},
{
envKey: 'WECOM_WEBHOOK_URL',
group: 'notification',
label: '企业微信 Webhook 地址',
description: '企业微信机器人 Webhook URL',
type: 'url',
sensitive: false,
},
];
```
### 5.2 配置组调整
```typescript
// 将 'feishu' 组改为 'notification' 组
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
// ...
{
key: 'notification',
label: '通知服务',
description: '飞书、企业微信等通知渠道配置',
icon: 'bell',
},
// ...
];
```
---
## 6. 调用层迁移
### 6.1 review.ts 改造
```typescript
import { getNotificationManager } from '../services/notification-manager';
// PR事件处理
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
// ... 原有逻辑 ...
const context: NotificationContext = {
prTitle: pullRequest.title,
prUrl: pullRequest.html_url,
prNumber: pullRequest.number,
reviewers: reviewerUsernames,
repository: repo.name,
owner: repo.owner.login,
actor: body.sender?.login,
};
const notificationManager = getNotificationManager();
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await notificationManager.notifyPrCreated(context);
}
if (body.action === 'review_requested' && body.requested_reviewer) {
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
await notificationManager.notifyPrReviewerAssigned(context);
}
// ... 继续原有逻辑 ...
}
// Issue事件处理
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
// ...
const context: NotificationContext = {
issueTitle: issue.title,
issueUrl: issue.html_url,
issueNumber: issue.number,
creator: creatorUsername,
assignees: assigneeUsernames,
repository: repository.name,
actor: body.sender?.login,
};
if (action === 'opened' && assigneeUsernames.length > 0) {
await notificationManager.notifyIssueCreated(context);
} else if (action === 'closed') {
await notificationManager.notifyIssueClosed(context);
} else if (action === 'assigned') {
await notificationManager.notifyIssueAssigned(context);
}
}
```
---
## 7. 落地决策(已执行)
### 7.1 旧飞书服务下线
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`
- 通过单一通知入口避免重复发送与配置路径分裂问题。
### 7.2 运行时配置生效策略
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
### 7.3 落地检查清单
- [x] 飞书与企业微信通过统一通知抽象发送
- [x] 旧飞书服务文件已下线
- [x] 控制器通知链路已去重
- [x] 前端新增独立“通知管理”菜单与页面
---
## 8. 实施计划
### 8.1 阶段划分
| 阶段 | 任务 | 文件 | 优先级 |
|------|------|------|--------|
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
| 6 | 调用层迁移 | `review.ts` | P1 |
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
### 8.2 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
---
## 9. 测试策略
### 9.1 单元测试
```typescript
// __tests__/notification.test.ts
describe('NotificationService', () => {
describe('FeishuNotificationService', () => {
it('should generate correct signature', () => {
// 测试签名算法
});
it('should format message correctly', () => {
// 测试消息格式转换
});
});
describe('WeComNotificationService', () => {
it('should use mentioned_list for @users', () => {
// 测试@用户格式
});
});
describe('NotificationManager', () => {
it('should broadcast to all enabled services', async () => {
// 测试广播逻辑
});
it('should not fail if one service fails', async () => {
// 测试容错
});
});
});
```
### 9.2 集成测试
- 配置真实飞书机器人测试消息发送
- 配置企业微信机器人测试消息发送
- 验证同时配置多个服务时的行为
---
## 10. 附录
### 10.1 飞书与企业微信API对比详情
#### 飞书消息格式
```json
{
"msg_type": "text",
"content": {
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
}
}
```
#### 企业微信消息格式
```json
{
"msgtype": "text",
"text": {
"content": "Hello World",
"mentioned_list": ["wangqing", "@all"],
"mentioned_mobile_list": ["13800001111"]
}
}
```
### 10.2 扩展指南
添加新通知平台步骤:
1.`types.ts` 添加新的 `NotificationProvider` 类型
2.`providers/` 创建新的服务类,继承 `BaseNotificationService`
3.`notification-factory.ts` 添加创建逻辑
4.`config-schema.ts` 添加配置字段
5. 在 Admin Dashboard 添加UI配置项
---
**文档版本**: 1.0
**创建日期**: 2026-03-24
**作者**: Sisyphus
**状态**: 已实施(持续验证中)

View File

@@ -0,0 +1,821 @@
# 技术设计文档:可插拔 LLM Provider 架构
> **状态**: Draft
> **作者**: AI Architect
> **日期**: 2026-03-04
> **相关 Issue**: N/A
---
## 目录
- [0. 设计原则](#0-设计原则)
- [1. 目录结构](#1-目录结构新增改动部分)
- [2. 数据库表结构](#2-数据库表结构sqlite-ddl)
- [3. LLM Gateway 核心接口](#3-llm-gateway-核心-typescript-接口)
- [4. Provider Adapter 差异映射](#4-四个-provider-adapter-核心差异映射)
- [5. 后端 REST API 契约](#5-后端-rest-api-契约)
- [6. 密钥安全设计](#6-密钥安全设计)
- [7. 前端配置页设计](#7-前端配置页设计)
- [8. 现有调用点改造清单](#8-现有调用点改造清单)
- [9. 实施阶段建议](#9-实施阶段建议)
- [10. 风险与缓解](#10-风险与缓解)
---
## 0. 设计原则
| 原则 | 说明 |
|---|---|
| **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT``WEBHOOK_SECRET``DATABASE_PATH` |
| **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`Responses API`anthropic`Messages API`gemini`generateContent API |
| **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` |
| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB主密钥通过环境变量 `ENCRYPTION_KEY` 传入hex 编码64 字符 = 32 字节),未设置则拒绝启动 |
| **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 |
### 开源参考
| 借鉴点 | 参考项目 | 具体模式 |
|---|---|---|
| 接口先行 + provider registry | [Vercel AI SDK](https://github.com/vercel/ai) | `LanguageModelV3` spec-first adapter版本化接口 |
| Provider transformation 差异映射 | [LiteLLM](https://github.com/BerriAI/litellm) | 每个 provider 独立 `transformation.py`,标准化 error/usage |
| Runtime factory + 多 provider 配置 | [LobeChat](https://github.com/lobehub/lobe-chat) | `createOpenAICompatibleRuntime` 工厂 + 动态 model list |
| 配置驱动多 endpoint | [LibreChat](https://github.com/danny-avila/LibreChat) | `librechat.yaml` Custom Endpoints 模式 |
| 能力声明/能力检测 | [Continue](https://github.com/continuedev/continue) | `supportsTools` / `supportsImages` 声明式 capability |
---
## 1. 目录结构(新增/改动部分)
```
src/
├── db/
│ ├── database.ts # bun:sqlite 初始化
│ ├── migrations/
│ │ └── 001_init.ts # 建表 DDL
│ └── repositories/
│ ├── provider-repo.ts # llm_providers CRUD
│ ├── model-role-repo.ts # model_role_assignments CRUD
│ ├── secret-repo.ts # 加密 read/write
│ └── settings-repo.ts # system_settings KV
├── llm/
│ ├── types.ts # 统一内部请求/响应类型
│ ├── capabilities.ts # 能力声明枚举
│ ├── errors.ts # LLM 层标准化错误
│ ├── gateway.ts # LLM Gateway 入口(按 provider 路由)
│ ├── tool-converter.ts # 工具定义 → 各 provider 格式转换
│ └── providers/
│ ├── base.ts # LLMProvider 抽象接口
│ ├── openai-compatible.ts # 现有兼容格式 adapter
│ ├── openai-responses.ts # OpenAI Responses API adapter
│ ├── anthropic.ts # Anthropic Messages API adapter
│ └── gemini.ts # Gemini generateContent adapter
├── crypto/
│ └── secrets.ts # AES-256-GCM 加解密 + master key 管理
├── controllers/
│ └── llm-config.ts # 新 REST API替代 config.ts 中 LLM 部分)
└── config/
├── config-manager.ts # 精简:只管非 LLM 配置gitea/feishu/app/admin/review 非模型部分)
└── config-schema.ts # 移除 openai 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绑定到
-- 一个 provider + 具体 model支持不同场景用不同 provider。
CREATE TABLE model_role_assignments (
role TEXT PRIMARY KEY CHECK (role IN (
'planner',
'specialist',
'judge'
)),
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- 表4: system_settings — 通用 KV 设置
-- ============================================================
-- 存放非 LLM 的业务配置(由 UI 直接写入 DB
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
is_sensitive INTEGER NOT NULL DEFAULT 0, -- 1=加密存储(复用 crypto 模块)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 索引
CREATE INDEX idx_providers_type ON llm_providers(type);
CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
```
### 2.3 字段说明补充
| 表.字段 | 说明 |
|---|---|
| `llm_providers.type` | 决定使用哪个 adapter 实现 |
| `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint |
| `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 |
| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 |
| `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 |
| `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`GET API 返回 masked |
---
## 3. LLM Gateway 核心 TypeScript 接口
### 3.1 统一消息与请求/响应类型
```typescript
// ── src/llm/types.ts ────────────────────────────────────────
/** 模型角色枚举 */
export type ModelRole = 'planner' | 'specialist' | 'judge';
/** 统一消息格式(内部表达,不暴露 provider 差异) */
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
toolCallId?: string; // role=tool 时关联的 tool call ID
toolCalls?: LLMToolCall[]; // role=assistant 时返回的 tool calls
}
export interface LLMToolCall {
id: string;
name: string;
arguments: string; // JSON string
}
/** 工具定义(内部通用格式,由 tool-converter.ts 转为各 provider 格式) */
export interface LLMToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
}
/** 统一请求 */
export interface LLMChatRequest {
messages: LLMMessage[];
model: string;
temperature?: number;
maxTokens?: number;
responseFormat?: 'text' | 'json'; // 抽象 JSON mode
tools?: LLMToolDefinition[];
/** provider 透传配置(如 Anthropic thinking、Gemini safetySettings */
providerOptions?: Record<string, unknown>;
}
/** 统一响应 */
export interface LLMChatResponse {
content: string | null;
toolCalls: LLMToolCall[];
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error';
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
raw?: unknown; // 保留原始响应供调试
}
```
### 3.2 能力模型
```typescript
// ── src/llm/capabilities.ts ─────────────────────────────────
export interface ProviderCapabilities {
/** 是否支持 tool/function calling */
supportsTools: boolean;
/** 是否支持原生 JSON 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
* @param request 请求(不含 model由角色映射决定
*/
async chatForRole(
role: ModelRole,
request: Omit<LLMChatRequest, 'model'>
): Promise<LLMChatResponse>;
/**
* 用指定 provider 直接调用(连通性测试用)
*/
async chatDirect(
providerId: string,
request: LLMChatRequest
): Promise<LLMChatResponse>;
/** 配置变更时清除单个 provider 缓存 */
invalidateProvider(providerId: string): void;
/** 清除全部缓存(全局配置变更时) */
invalidateAll(): void;
}
```
---
## 4. 四个 Provider Adapter 核心差异映射
### 4.1 总览对照表
| 特性 | openai_compatible | openai_responses | anthropic | gemini |
|---|---|---|---|---|
| **SDK/HTTP** | `openai` npm (`chat.completions`) | `openai` npm (`responses.create`) | `@anthropic-ai/sdk` | `@google/generative-ai` 或 REST |
| **系统指令** | `messages[0].role='system'` | `instructions` 参数 | `system` 顶层参数 | `systemInstruction` 参数 |
| **JSON mode** | `response_format: {type:'json_object'}` | `text.format: {type:'json_object'}` | 无原生支持 → prompt 指令 + `JSON.parse` | `responseMimeType: 'application/json'` + `responseSchema` |
| **工具调用请求** | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].name` + `input_schema` | `tools[].functionDeclarations[].{name,description,parameters}` |
| **工具结果返回** | `role: 'tool'` + `tool_call_id` | `type: 'function_call_output'` + `call_id` | `role: 'user'` + `content: [{type:'tool_result', tool_use_id}]` | `role: 'function'` + `parts: [{functionResponse}]` |
| **finish_reason** | `stop` / `tool_calls` / `length` | `stop` / `tool_calls` / ... | `end_turn` / `tool_use` / `max_tokens` | `STOP` / `FUNCTION_CALL` / `MAX_TOKENS` |
| **Token 用量** | `usage.{prompt,completion}_tokens` | `usage.{input,output}_tokens` | `usage.{input,output}_tokens` | `usageMetadata.{prompt,candidates}TokenCount` |
### 4.2 各 Adapter 核心转换逻辑
#### 4.2.1 openai_compatible现有兼容格式
```typescript
// 请求转换:几乎直通(这就是现有代码逻辑的抽象)
// - LLMMessage → OpenAI ChatCompletionMessage (直接映射)
// - responseFormat='json' → { type: 'json_object' }
// - tools → tools[].function (直接映射)
//
// 响应转换:
// - choices[0].message.content → content
// - choices[0].message.tool_calls → toolCalls
// - choices[0].finish_reason → finishReason (直接映射)
// - usage.{prompt,completion}_tokens → usage
```
#### 4.2.2 openai_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 ] │
│ └──────────────────────────────────────────────────────────────┘
│ [保存角色分配]
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
│ ├── Agent 分级审查参数small/medium 阈值、token budget、triage 开关
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
```
### 7.2 交互规则
| 交互 | 行为 |
|---|---|
| **添加 Provider** | 弹出对话框;类型选择后动态显示/隐藏 `base_url` 字段 |
| **API Key 输入** | 已有 key 时展示 `••••••••`readonly 占位);清空内容后保存 = 删除 key输入新值 = 替换(调用 `PUT /key`);未修改 = 不发请求 |
| **测试连接** | 点击后调 `POST /providers/:id/test`;显示 spinner → 成功绿色 toast延迟+模型)/ 失败红色 toast错误信息 |
| **角色分配下拉** | 仅显示 `is_enabled=true``hasKey=true` 的 provider选择后自动填充该 provider 的 `default_model`(用户可修改) |
| **禁用 Provider** | 如果有角色绑定到此 provider → 弹确认对话框:"此 Provider 正被以下角色使用:[...],禁用后这些角色将无法调用 LLM。确定禁用" |
| **删除 Provider** | 同上,级联影响提示更强烈 |
| **模型建议** | 根据 provider type 显示常见模型建议列表(硬编码在前端,仅作参考,不限制输入) |
### 7.3 模型建议列表(前端硬编码参考)
```typescript
const MODEL_SUGGESTIONS: Record<string, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
};
```
---
## 8. 现有调用点改造清单
### 8.1 后端代码改造
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|---|---|---|---|---|
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent任务化分级编排skip/light/full | Agent 编排 |
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway``reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
| 5 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
| 6 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
| 7 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
| 8 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
### 8.2 前端代码改造
| # | 文件 | 改造内容 |
|---|---|---|
| 1 | `frontend/src/services/configService.ts` | 新增 `llmProviderService.ts`Provider CRUD + Key 管理 + Role 管理 + Test |
| 2 | `frontend/src/components/ConfigManager.tsx` | 添加 "LLM Providers" Tab/Card引入新组件 |
| 3 | 新增 | `frontend/src/components/llm/ProviderList.tsx` — Provider 列表表格 |
| 4 | 新增 | `frontend/src/components/llm/ProviderDialog.tsx` — 添加/编辑对话框 |
| 5 | 新增 | `frontend/src/components/llm/RoleAssignment.tsx` — 角色分配面板 |
### 8.3 配置层改造
| 变更 | 说明 |
|---|---|
| `config-manager.ts` | 精简为只管非 LLM 配置;数据源统一为 `system_settings` 表 |
| `config-schema.ts` | 移除 `openai` group 及其字段;保留 gitea/feishu/app/admin/review非模型字段 |
| `controllers/config.ts` | LLM 相关接口迁到 `controllers/llm-config.ts`;通用配置接口改读写 DB |
| `.env.example` | 移除 `OPENAI_*``REVIEW_MODEL_*`;仅保留启动参数 |
---
## 9. 实施阶段建议
| 阶段 | 内容 | 依赖 | 估时 |
|---|---|---|---|
| **Phase 1: 基础设施** | DB 层 (`bun:sqlite` 初始化 + DDL) + crypto 模块 | 无 | 1d |
| **Phase 2: LLM 抽象层** | `src/llm/` 全部types + capabilities + errors + gateway + 4 adapters + tool-converter | Phase 1 | 2d |
| **Phase 3: 后端 API + 调用点替换** | `controllers/llm-config.ts` + 替换 11 个现有 OpenAI 调用点 + 测试 | Phase 2 | 1.5d |
| **Phase 4: 前端改造** | Provider 管理 + 角色分配 + 连接测试 UI + 通用设置切 DB | Phase 3 | 1.5d |
| **Phase 5: 清理与验收** | 删除旧代码 + 更新文档 + E2E 测试 + `.env.example` 精简 | Phase 4 | 0.5d |
**总计约 6.5 人天。**
### 关键里程碑
```
Day 1: DB + crypto 就绪,配置写入链路打通
Day 3: LLM Gateway 可用4 个 adapter 通过单元测试
Day 4.5: 后端 API 完成,所有调用点已替换,`bun test` 全绿
Day 6: 前端配置页可用,可通过 UI 添加/测试 Provider
Day 6.5: 旧代码清理完毕文档更新Ready for review
```
---
## 10. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse |
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理finish reason 映射表全覆盖测试 |
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API 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=5174
WEBHOOK_SECRET=your_webhook_secret
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
# 以下配置已迁入数据库,通过 Web UI 管理:
# - LLM Provider 配置API Key / Base URL / Model
# - Gitea 配置API URL / Token
# - 飞书配置Webhook URL / Secret
# - Review 引擎配置
```

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 视觉回归。

69
docs/getting-started.md Normal file
View File

@@ -0,0 +1,69 @@
# Getting Started
## Prerequisites
- Bun >= 1.2.5
- A reachable Gitea instance
- At least one LLM provider credential
## Install
```bash
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
```
`bun install` at repository root installs frontend dependencies via `postinstall`.
If lifecycle scripts are disabled:
```bash
bun run bootstrap
```
## Minimal environment
Create `.env`:
```bash
PORT=5174
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
```
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
## Run
```bash
bun run dev
# or
bun run start
```
## First login
- Open `http://your-server:5174`
- Default admin password is `password` on first boot
- Change admin password immediately after login
## Webhook setup
### Option A: Admin UI (recommended)
In repository list, click enable to auto-provision webhook.
### Option B: Manual
In Gitea repository settings:
- URL: `http://your-server:5174/webhook/gitea`
- Content Type: `application/json`
- Secret: same value as dashboard webhook secret
- Events: Pull Request + Status
## Health endpoint
Use `/api/health` to check service status.

View File

@@ -0,0 +1,69 @@
# 快速开始
## 环境要求
- Bun >= 1.2.5
- 可访问的 Gitea 实例
- 至少一个 LLM 提供商凭证
## 安装
```bash
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
```
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
如果你的环境禁用了生命周期脚本:
```bash
bun run bootstrap
```
## 最小环境变量
创建 `.env` 文件:
```bash
PORT=5174
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
```
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
## 启动服务
```bash
bun run dev
# 或
bun run start
```
## 首次登录
- 访问 `http://your-server:5174`
- 首次启动默认管理员密码为 `password`
- 登录后请立即修改管理员密码
## Webhook 配置
### 方式 A管理后台推荐
在仓库列表点击启用按钮,由系统自动配置 webhook。
### 方式 B手动配置
在 Gitea 仓库设置中配置:
- URL`http://your-server:5174/webhook/gitea`
- Content Type`application/json`
- Secret与管理后台中的 Webhook Secret 保持一致
- 事件Pull Request + Status
## 健康检查
可通过 `/api/health` 查看服务状态。

52
docs/review-engines.md Normal file
View File

@@ -0,0 +1,52 @@
# Review Engines
## Overview
The system supports two engines:
- `agent`: native Agent review pipeline
- `codex`: Codex CLI-backed review pipeline
Engine is selected by `REVIEW_ENGINE` runtime configuration.
## Agent engine
The Agent engine runs code reviews using a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
### Review behavior
- **Main Agent**: The entrypoint agent that coordinates the review process. It uses the tools provided to analyze the code changes.
- **Dynamic Subagents**: The main agent can dynamically spawn subagents to perform specific tasks, such as searching code or reading files, if needed.
- **Deterministic Publishing**: Review findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting them back to Gitea.
### Review modes
- `skip`: Low-risk changes may bypass the agent review entirely.
- `light`: Minimal checks for low-risk code changes.
- `full`: Full review for risky or larger changes.
### Size policy
`small`/`medium`/`large` thresholds are used to classify the change size, which determines the execution mode and token budgets.
## Codex engine
Codex engine runs review through Codex CLI with independent runtime settings:
- `CODEX_API_URL`
- `CODEX_API_KEY`
- `CODEX_MODEL`
- `CODEX_TIMEOUT_MS`
- `CODEX_REVIEW_PROMPT`
## Event support
Both engines process:
- Pull request webhook events
- Commit status webhook events
## Output
- PR/commit summary comment
- Line-level findings with confidence and severity

View File

@@ -0,0 +1,52 @@
# 审查引擎
## 概览
系统支持两种审查引擎:
- `agent`:内置 Agent 审查流水线
- `codex`:基于 Codex CLI 的审查流水线
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
## Agent 引擎
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
### 审查行为
- **主 Agent**:协调审查流程的入口 Agent。它使用提供的工具来分析代码变更。
- **动态子 Agent**:主 Agent 可以根据需要动态生成子 Agent以执行特定任务例如搜索代码或读取文件
- **确定性发布**:审查发现的问题与评论会在 Agent 循环之外进行收集和处理。系统会在将结果发布回 Gitea 之前,对发现的问题进行确定性的规范化、去重和过滤。
### 审查模式
- `skip`:低风险改动可完全跳过 Agent 审查。
- `light`:对低风险代码执行最小化检查。
- `full`:对高风险或大规模改动执行完整审查。
### 规模策略
`small` / `medium` / `large` 阈值用于对变更规模进行分类,从而决定执行模式与 Token 预算。
## Codex 引擎
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
- `CODEX_API_URL`
- `CODEX_API_KEY`
- `CODEX_MODEL`
- `CODEX_TIMEOUT_MS`
- `CODEX_REVIEW_PROMPT`
## 事件支持
两种引擎都支持:
- Pull Request webhook 事件
- Commit Status webhook 事件
## 输出
- PR/提交总结评论
- 行级问题(含置信度与严重性)

23
docs/screenshots.md Normal file
View File

@@ -0,0 +1,23 @@
# Screenshot Gallery
All screenshots are captured from local development service.
## Repository management (`/repos`)
![Repository management](./assets/page-repos.png)
## System configuration (`/config`)
![System configuration](./assets/page-config.png)
## Notification management (`/notifications`)
![Notification management](./assets/page-notifications.png)
## Review configuration (`/review-config`)
![Review configuration](./assets/page-review-config.png)
## Language
- 中文: [screenshots.zh-CN.md](./screenshots.zh-CN.md)

23
docs/screenshots.zh-CN.md Normal file
View File

@@ -0,0 +1,23 @@
# 截图集
以下截图来自本地开发环境。
## 仓库管理(`/repos`
![仓库管理](./assets/page-repos.png)
## 系统配置(`/config`
![系统配置](./assets/page-config.png)
## 通知管理(`/notifications`
![通知管理](./assets/page-notifications.png)
## 审查配置(`/review-config`
![审查配置](./assets/page-review-config.png)
## 语言切换
- English: [screenshots.md](./screenshots.md)

View File

@@ -11,6 +11,6 @@ RUN bun install --no-frozen-lockfile
COPY src ./src
COPY tsconfig.json .
EXPOSE 3000
EXPOSE 5174
CMD ["bun", "run", "start"]

258
e2e/README.md Normal file
View File

@@ -0,0 +1,258 @@
# E2E 真实 PR 审查测试指南
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
## 前置条件
- Docker & Docker Compose
- `openssl`(签名计算)
- `python3`JSON 解析)
- 本项目源码
## 架构概览
```
Gitea (port 3333) ←→ Assistant (port 3334)
↑ ↑
webhook clone + comment
(PR 事件) (git + API)
```
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`
- **Assistant**AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
## 一键启动(自动化方式)
```bash
# 1. 启动容器
docker compose -f docker-compose.e2e.yml up -d
# 2. 等待 Gitea healthy 后创建用户Gitea 不允许 root 执行 admin 命令)
docker exec e2e-gitea su git -c \
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
--email admin@e2e-test.local --admin --must-change-password=false"
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR
bash ./e2e/seed.sh
# 4. 用 seed 输出的 token 重启 assistant使 GITEA_ACCESS_TOKEN 生效)
# seed.sh 会在最后打印实际 token 值
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
# 5. 通过 Admin API 更新运行时配置
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
-H "Content-Type: application/json" -d '{"password": "password"}')
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{
"GITEA_API_URL": "http://gitea:3000/api/v1",
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
"WEBHOOK_SECRET": "e2e-test-secret"
}'
# 6. 运行 E2E 测试
bash ./e2e/test.sh
```
## 分步详解与踩坑点
### 1. 启动容器
```bash
docker compose -f docker-compose.e2e.yml up -d
```
**踩坑**`ENCRYPTION_KEY``WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
```yaml
assistant:
environment:
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...64位hex}
- WEBHOOK_SECRET=e2e-test-secret
```
### 2. 创建 Gitea 用户
```bash
docker exec e2e-gitea su git -c \
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
--email admin@e2e-test.local --admin --must-change-password=false"
```
**踩坑**`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
### 3. Seed 初始化
```bash
bash ./e2e/seed.sh
```
Seed 脚本会执行:
1. 等待 Gitea 就绪
2. 创建管理员用户(如已存在则跳过)
3. 生成 API Token
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
5. 配置 Assistant 设置(需要 assistant 已启动)
6. 配置 Gitea Webhook指向 `http://assistant:5174/webhook/gitea`
7. 创建 PR #1`feature/add-user-handler``main`
**踩坑**seed.sh 第 5 步"配置 Assistant 设置"可能失败assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
### 4. 更新 Assistant 运行时配置
```bash
# 获取 JWT
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
-H "Content-Type: application/json" -d '{"password": "password"}' | \
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 更新三个关键配置
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{
"GITEA_API_URL": "http://gitea:3000/api/v1",
"GITEA_ACCESS_TOKEN": "<token>",
"WEBHOOK_SECRET": "e2e-test-secret"
}'
```
**关键**
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`Docker 内部地址),不是 `localhost` 或宿主机地址
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 tokenassistant 用它 clone 仓库和发布评论
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
### 5. 触发 PR 审查
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发
```bash
# 获取 PR 信息
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
-H "Authorization: token <token>")
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
# 构造 webhook payload需包含 head/base SHA
cat > /tmp/webhook_payload.json << EOF
{
"action": "opened",
"number": 1,
"pull_request": {
"number": 1,
"title": "feat: add user handler",
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
"base": { "ref": "main", "sha": "$BASE_SHA",
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
},
"repository": {
"full_name": "e2e-admin/e2e-test-repo",
"name": "e2e-test-repo",
"owner": { "login": "e2e-admin" },
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
},
"sender": { "login": "e2e-admin" }
}
EOF
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
# 发送 webhook 必须用 --data-binary 而非 -d否则换行符被剥离导致签名不匹配
curl -s -X POST "http://localhost:3334/webhook/gitea" \
-H "Content-Type: application/json" \
-H "X-Gitea-Event: pull_request" \
-H "X-Gitea-Signature: ${SIG}" \
--data-binary @/tmp/webhook_payload.json
```
### 6. 验证审查结果
```bash
# 等待审查完成
sleep 10
# 检查 assistant 日志
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
# 通过 API 查看 run 详情
curl -sf "http://localhost:3334/admin/api/review/runs" \
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
# 检查 Gitea PR 评论summary
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
-H "Authorization: token <token>" | python3 -c "
import sys,json
for c in json.load(sys.stdin):
print(c['body'][:200])
"
# 检查 Gitea PR Reviews行级评论
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
-H "Authorization: token <token>"
```
## 验证检查清单
| # | 检查项 | 验证方式 |
|---|--------|----------|
| 1 | Gitea 容器 healthy | `docker ps``curl localhost:3333/api/v1/version` |
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
| 5 | Agent 审查执行完成 | run status = `succeeded` |
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
| 10 | Finding published=true | DB 中 finding.published = true |
## Mock LLM vs 真实 LLM
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|------|----------------|----------|
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
| Findings | 固定 1 条eval 安全问题) | 根据实际代码动态发现 |
| 速度 | <1s | 10-60s |
| 用途 | 集成链路验证 | 审查质量验证 |
## 常见问题
### `ENCRYPTION_KEY is required`
**原因**`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
**修复**:已在 compose 文件中添加默认值。
### `Webhook签名验证失败`
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
### `could not read Username for 'http://gitea:3000'`
**原因**`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
### `Gitea is not supposed to be run as root`
**原因**Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
### Gitea API URL 指向 localhost
**原因**assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`Docker 内部地址)。
### 评论未发布到 Gitea
**原因**Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
## 清理
```bash
docker compose -f docker-compose.e2e.yml down -v
```
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。

View File

@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
done
echo "=== [2/6] 创建管理员用户 ==="
docker exec e2e-gitea gitea admin user create \
--username "${ADMIN_USER}" \
--password "${ADMIN_PASS}" \
--email "${ADMIN_EMAIL}" \
--admin \
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
docker exec e2e-gitea su git -c "gitea admin user create \
--username '${ADMIN_USER}' \
--password '${ADMIN_PASS}' \
--email '${ADMIN_EMAIL}' \
--admin \
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
echo "=== [3/6] 生成 API Token ==="
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
@@ -115,7 +115,43 @@ git commit -m "feat: add user handler"
git push origin feature/add-user-handler 2>/dev/null
popd > /dev/null
echo "=== [5/6] 配置 Webhook ==="
echo "=== [5/7] 配置 Assistant 设置 ==="
ADMIN_DEFAULT_PASS="password"
# Wait for assistant to be healthy
for i in $(seq 1 20); do
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
echo " Assistant 已就绪"
break
fi
echo " 等待 Assistant... ($i/20)"
sleep 3
done
# Login to get JWT
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
-H "Content-Type: application/json" \
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
if [ -z "${ADMIN_JWT}" ]; then
echo " WARNING: 无法获取管理员 JWT跳过 assistant 配置"
else
echo " JWT 获取成功,配置 assistant 设置..."
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-d "{
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
\"REVIEW_ENGINE\": \"agent\",
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
fi
echo "=== [6/7] 配置 Webhook ==="
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
@@ -124,13 +160,12 @@ curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
\"active\": true,
\"events\": [\"pull_request\"],
\"config\": {
\"url\": \"http://assistant:3000/webhook/gitea\",
\"url\": \"http://assistant:5174/webhook/gitea\",
\"content_type\": \"json\",
\"secret\": \"${WEBHOOK_SECRET}\"
}
}" > /dev/null 2>&1 || echo " Webhook 配置失败(可能已存在)"
echo "=== [6/6] 创建 Pull Request ==="
echo "=== [7/7] 创建 Pull Request ==="
PR_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \

View File

@@ -2,7 +2,6 @@
set -euo pipefail
# E2E Test Script
# 验证 AI 代码审查是否在 PR 上产生了评论
#
# 前置条件:
# 1. docker compose -f docker-compose.e2e.yml up -d
@@ -17,10 +16,12 @@ fi
source "${ENV_FILE}"
MAX_WAIT=180 # 最多等待 3 分钟
MAX_WAIT=240
POLL_INTERVAL=5
PASS=0
FAIL=0
RUN_ID=""
LATEST_DETAIL='{}'
echo "=== E2E 测试开始 ==="
echo " Gitea: ${GITEA_URL}"
@@ -38,6 +39,12 @@ else
FAIL=$((FAIL + 1))
fi
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
echo " E2E_MOCK_LLM=1 (shell env)"
else
echo " E2E_MOCK_LLM 由 assistant 容器环境决定docker-compose.e2e.yml 已配置)"
fi
# ─── 测试 2: Gitea API 可用 ───
echo "[TEST 2] Gitea API 可用性"
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
@@ -63,69 +70,121 @@ else
FAIL=$((FAIL + 1))
fi
# ─── 测试 4: 等待 AI 审查评论出现 ───
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s"
COMMENT_FOUND=false
WAITED=0
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
import sys, json
comments = json.load(sys.stdin)
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
print(len(ai))
" 2>/dev/null || echo "0")
if [ "${AI_COMMENTS}" -gt "0" ]; then
COMMENT_FOUND=true
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
PASS=$((PASS + 1))
break
fi
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
sleep ${POLL_INTERVAL}
WAITED=$((WAITED + POLL_INTERVAL))
done
if [ "${COMMENT_FOUND}" = false ]; then
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
FAIL=$((FAIL + 1))
echo " --- 调试信息 ---"
echo " PR 所有评论:"
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
echo " Assistant review runs:"
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
fi
# ─── 测试 5: Review Run 状态检查 ───
echo "[TEST 5] Review Run 状态"
echo "[TEST 4] Admin 登录"
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
-H "Content-Type: application/json" \
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
if [ -n "${ADMIN_JWT}" ]; then
echo " ✅ PASS: Admin JWT 获取成功"
PASS=$((PASS + 1))
else
echo " ❌ FAIL: Admin JWT 获取失败"
FAIL=$((FAIL + 1))
fi
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s"
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
if [ "${RUN_COUNT}" -gt "0" ]; then
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
PASS=$((PASS + 1))
WAITED=0
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
PASS=$((PASS + 1))
break
fi
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
sleep ${POLL_INTERVAL}
WAITED=$((WAITED + POLL_INTERVAL))
done
echo "${RUNS}" | python3 -c "
import sys, json
data = json.load(sys.stdin)
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
for r in runs[:3]:
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
" 2>/dev/null || true
if [ -z "${RUN_ID}" ]; then
echo " ❌ FAIL: 未发现 review run"
FAIL=$((FAIL + 1))
fi
if [ -n "${RUN_ID}" ]; then
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
fi
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
import json,sys
d=json.load(sys.stdin)
t=d.get("sessionTree") or {}
main_type=t.get("agentType")
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
inv=t.get("invocations",[])
has_spawn="spawn_subagent" in main_tools
child_ok=False
if inv:
child=inv[0].get("childSession") or {}
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
child_ok=("search_code" in child_tools and "read_file" in child_tools)
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
' 2>/dev/null || echo "bad")
if [ "${TREE_ASSERT}" = "ok" ]; then
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file"
PASS=$((PASS + 1))
else
echo " ❌ FAIL: 无 review runs"
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
echo "[TEST 7] run details 包含 findings 与评论记录"
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
import json,sys
d=json.load(sys.stdin)
findings=d.get("findings",[])
comments=d.get("comments",[])
ok=(len(findings) > 0 and len(comments) > 0)
print("ok" if ok else "bad")
' 2>/dev/null || echo "bad")
if [ "${DETAIL_ASSERT}" = "ok" ]; then
echo " ✅ PASS: run details 存在 findings/comments"
PASS=$((PASS + 1))
else
echo " ❌ FAIL: run details 缺少 findings 或 comments"
FAIL=$((FAIL + 1))
fi
echo "[TEST 8] Gitea 评论产物summary + line comments"
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
import json,sys
arr=json.load(sys.stdin)
cnt=0
for c in arr:
body=c.get("body") or ""
if "审查" in body or "review" in body.lower() or "AI" in body:
cnt += 1
print(cnt)
' 2>/dev/null || echo "0")
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
PASS=$((PASS + 1))
else
echo " ❌ FAIL: Gitea 评论产物不足summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
echo " --- issue comments ---"
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
echo " --- line comments ---"
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
if [ ${FAIL} -gt 0 ]; then
echo ""
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
echo " 1. OPENAI_API_KEY 已正确配置"
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
echo "⚠️ 部分测试失败。请检查:"
echo " 1. docker compose e2e 容器均 healthy"
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
exit 1
else
echo ""

4
frontend/.gitignore vendored
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,9 +7,15 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"ui:regression": "bun run scripts/ui-regression.ts && vitest run src/components/llm/__tests__/ModelCombobox.test.tsx src/components/llm/__tests__/ProviderList.test.tsx src/components/llm/__tests__/RoleAssignment.test.tsx src/components/llm/__tests__/TestResultDialog.test.tsx",
"ui:visual": "playwright test -c playwright.config.ts",
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@@ -30,7 +36,11 @@
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@eslint/js": "^9.36.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -40,12 +50,14 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"happy-dom": "^20.8.3",
"postcss": "^8.5.6",
"tailwindcss": "^3",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.8",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.7"
"vite": "^7.1.7",
"vitest": "^4.0.18"
}
}

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,12 @@ import { LoginPage } from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import { RepositoryManager } from './components/RepositoryManager';
import { ConfigManager } from './components/ConfigManager';
import { NotificationConfigPage } from './components/NotificationConfigPage';
import { ReviewConfigPage } from './components/ReviewConfigPage';
import ReviewSessionsPage from './pages/ReviewSessionsPage';
import { Toaster } from "@/components/ui/sonner"
import { useTheme } from 'next-themes'
import { ColorPaletteProvider } from './hooks/useColorPalette';
function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@@ -15,7 +20,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
<div className="flex flex-col items-center gap-4">
<div className="relative flex h-12 w-12 items-center justify-center">
<div className="absolute h-full w-full rounded-full border-b-2 border-primary animate-spin"></div>
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 opacity-50 theme-spin-reverse-slow"></div>
<div className="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
</div>
<div className="text-sm font-mono tracking-widest text-primary/80 animate-pulse">INITIALIZING...</div>
@@ -31,7 +36,9 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function App() {
function AppContent() {
const { resolvedTheme } = useTheme();
return (
<BrowserRouter>
<Routes>
@@ -46,12 +53,23 @@ function App() {
<Route index element={<Navigate to="/repos" replace />} />
<Route path="repos" element={<RepositoryManager />} />
<Route path="config" element={<ConfigManager />} />
<Route path="notifications" element={<NotificationConfigPage />} />
<Route path="review-config" element={<ReviewConfigPage />} />
<Route path="review-runs" element={<ReviewSessionsPage />} />
<Route path="*" element={<Navigate to="/repos" replace />} />
</Route>
</Routes>
<Toaster theme="dark" />
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />
</BrowserRouter>
);
}
function App() {
return (
<ColorPaletteProvider>
<AppContent />
</ColorPaletteProvider>
);
}
export default App;

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,9 @@ interface ConfigGroupCardProps {
onFieldChange: (envKey: string, value: any) => void;
onReset: (keys: string[]) => void;
isResetting: boolean;
headerActions?: React.ReactNode;
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
}
export function ConfigGroupCard({
@@ -32,13 +36,15 @@ export function ConfigGroupCard({
onFieldChange,
onReset,
isResetting,
headerActions,
renderField,
}: ConfigGroupCardProps) {
const hasOverride = group.fields.some((f) => f.source === 'override');
const hasOverride = group.fields.some((f) => f.source === 'db');
const handleReset = () => {
// Only reset fields that actually have overrides
// Only reset fields that have been stored in DB
const keysToReset = group.fields
.filter((f) => f.source === 'override')
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
if (keysToReset.length > 0) {
@@ -47,46 +53,55 @@ export function ConfigGroupCard({
};
return (
<Card className="mb-8 glass-panel border-white/10 shadow-xl overflow-hidden group">
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
<Card className="mb-8 gap-0 py-0 theme-card-shell theme-interactive-elevate group">
<CardHeader className="theme-card-header flex flex-row items-start justify-between pb-4 space-y-0">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
{(() => {
const Icon = ICON_MAP[group.icon];
return Icon ? <Icon className="h-5 w-5 text-primary" /> : <span className="text-primary">{group.icon}</span>;
})()}
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
{group.label}
</CardTitle>
<CardDescription className="text-zinc-400">
<CardDescription className="text-muted-foreground">
{group.description}
</CardDescription>
</div>
</div>
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/50 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
{(headerActions || hasOverride) && (
<div className="flex items-center gap-2">
{headerActions}
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
)}
</div>
)}
</CardHeader>
<CardContent className="divide-y divide-white/5 p-6 bg-zinc-950/20">
{group.fields.map((field) => (
<ConfigFieldInput
key={field.envKey}
field={field}
value={localConfig[field.envKey]}
onChange={(val) => onFieldChange(field.envKey, val)}
/>
))}
<CardContent className="theme-card-content divide-y divide-border/50">
{group.fields.map((field) => {
const custom = renderField?.(field, localConfig[field.envKey], (val) => onFieldChange(field.envKey, val));
if (custom !== undefined) return <React.Fragment key={field.envKey}>{custom}</React.Fragment>;
return (
<ConfigFieldInput
key={field.envKey}
field={field}
value={localConfig[field.envKey]}
onChange={(val) => onFieldChange(field.envKey, val)}
/>
);
})}
</CardContent>
</Card>
);

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', '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

@@ -0,0 +1,379 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchConfig,
fetchNotificationTestHistory,
updateConfig,
resetConfig,
testNotification,
type NotificationTestProvider,
} from '@/services/configService';
import type {
ConfigResponse,
ConfigGroupDto,
ConfigFieldDto,
NotificationTestRecordDto,
} from '@/services/configService';
import { ConfigGroupCard } from './ConfigGroupCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
const NOTIFICATION_GROUPS = new Set(['notification']);
type ProviderCardMeta = {
key: NotificationTestProvider;
fieldPrefix: 'FEISHU_' | 'WECOM_';
label: string;
description: string;
enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED';
webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL';
};
const PROVIDER_CARDS: ProviderCardMeta[] = [
{
key: 'feishu',
fieldPrefix: 'FEISHU_',
label: '飞书通知',
description: '配置飞书机器人 Webhook 与签名密钥。',
enableKey: 'FEISHU_ENABLED',
webhookKey: 'FEISHU_WEBHOOK_URL',
},
{
key: 'wecom',
fieldPrefix: 'WECOM_',
label: '企业微信通知',
description: '配置企业微信群机器人 Webhook。',
enableKey: 'WECOM_ENABLED',
webhookKey: 'WECOM_WEBHOOK_URL',
},
];
export function NotificationConfigPage() {
const queryClient = useQueryClient();
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
const [hasChanges, setHasChanges] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
const {
data: testHistory,
isLoading: isHistoryLoading,
} = useQuery<NotificationTestRecordDto[], Error>({
queryKey: ['notification-test-history'],
queryFn: fetchNotificationTestHistory,
refetchInterval: 10000,
});
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
data.groups
.filter((g) => NOTIFICATION_GROUPS.has(g.key))
.forEach((group) => {
group.fields.forEach((field) => {
if (field.sensitive && field.hasValue) {
initialState[field.envKey] = '••••••••';
} else if (field.type === 'boolean') {
initialState[field.envKey] = field.value === 'true' || field.value === true;
} else {
initialState[field.envKey] = field.value ?? '';
}
});
});
setLocalConfig(initialState);
setHasChanges(false);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
onSuccess: () => {
toast.success('通知配置已成功保存');
queryClient.invalidateQueries({ queryKey: ['config'] });
setHasChanges(false);
},
onError: (err: Error) => {
toast.error(`保存失败: ${err.message}`);
},
});
const feishuTestMutation = useMutation({
mutationFn: () => testNotification('feishu'),
onSuccess: () => {
toast.success('飞书测试通知已发送');
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
},
onError: (err: Error) => {
toast.error(`飞书测试发送失败: ${err.message}`);
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
},
});
const wecomTestMutation = useMutation({
mutationFn: () => testNotification('wecom'),
onSuccess: () => {
toast.success('企业微信测试通知已发送');
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
},
onError: (err: Error) => {
toast.error(`企业微信测试发送失败: ${err.message}`);
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
},
});
const resetMutation = useMutation({
mutationFn: (keys: string[]) => resetConfig(keys),
onSuccess: () => {
toast.success('通知配置已重置');
queryClient.invalidateQueries({ queryKey: ['config'] });
},
onError: (err: Error) => {
toast.error(`重置失败: ${err.message}`);
},
});
const handleFieldChange = (envKey: string, value: any) => {
setLocalConfig((prev) => ({
...prev,
[envKey]: value,
}));
setHasChanges(true);
};
const handleSave = () => {
const payload: Record<string, string> = {};
for (const [key, val] of Object.entries(localConfig)) {
if (typeof val === 'boolean') {
payload[key] = val ? 'true' : 'false';
} else {
payload[key] = val === undefined || val === null ? '' : String(val);
}
}
saveMutation.mutate(payload);
};
const handleResetGroup = (keys: string[]) => {
if (confirm('确定要重置这些通知配置到默认值吗?这将立即生效。')) {
resetMutation.mutate(keys);
}
};
const notificationGroup = useMemo(
() => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)),
[data]
);
const providerGroups = useMemo<ConfigGroupDto[]>(() => {
if (!notificationGroup) {
return [];
}
return PROVIDER_CARDS.map((provider) => {
const fields = notificationGroup.fields.filter((field: ConfigFieldDto) =>
field.envKey.startsWith(provider.fieldPrefix)
);
return {
...notificationGroup,
key: `notification-${provider.key}`,
label: provider.label,
description: provider.description,
fields,
};
}).filter((group) => group.fields.length > 0);
}, [notificationGroup]);
const hasOverrides = useMemo(
() =>
providerGroups.some((g) =>
g.fields.some((f) => f.source === 'db')
),
[providerGroups]
);
const handleResetAll = () => {
if (providerGroups.length === 0) return;
const allOverrideKeys = providerGroups
.flatMap((g) => g.fields)
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
if (allOverrideKeys.length === 0) return;
if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) {
resetMutation.mutate(allOverrideKeys);
}
};
const canSendProviderTest = (provider: ProviderCardMeta): boolean => {
const enabled = localConfig[provider.enableKey] === true;
const webhook = localConfig[provider.webhookKey];
return enabled && typeof webhook === 'string' && webhook.trim().length > 0;
};
const getProviderMutation = (providerKey: NotificationTestProvider) => {
return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation;
};
const getProviderLabel = (provider: string): string => {
if (provider === 'feishu') return '飞书';
if (provider === 'wecom') return '企业微信';
return provider;
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-48 bg-muted/60" />
<Skeleton className="h-10 w-24 bg-muted/60" />
</div>
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
</div>
);
}
if (isError) {
return (
<div className="theme-error-panel flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
);
}
return (
<div className="theme-page-frame">
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
<div className="theme-page-actions">
<Button
variant="outline"
onClick={handleResetAll}
disabled={!hasOverrides || resetMutation.isPending}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
>
{saveMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> ...
</span>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
<div className="theme-page-content">
{providerGroups.map((group) => {
const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`);
if (!provider) {
return null;
}
const mutation = getProviderMutation(provider.key);
const canTest = canSendProviderTest(provider);
const canTestNow = canTest && !hasChanges && !saveMutation.isPending;
const testTitle = hasChanges
? '请先保存配置后再测试'
: canTest
? '发送测试通知'
: '请先启用并配置Webhook地址';
return (
<ConfigGroupCard
key={group.key}
group={group}
localConfig={localConfig}
onFieldChange={handleFieldChange}
onReset={handleResetGroup}
isResetting={resetMutation.isPending}
headerActions={
<Button
variant="outline"
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !canTestNow}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
title={testTitle}
>
{mutation.isPending ? '测试中...' : '测试发送'}
</Button>
}
/>
);
})}
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold tracking-wide text-foreground"></h3>
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
>
</Button>
</div>
{isHistoryLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full bg-muted/60" />
<Skeleton className="h-10 w-full bg-muted/60" />
</div>
) : (testHistory?.length ?? 0) === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{testHistory?.slice(0, 10).map((record) => (
<div
key={record.id}
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-border text-foreground">
{getProviderLabel(record.provider)}
</Badge>
<Badge
className={
record.status === 'success'
? 'bg-success/15 text-success border-success/30'
: 'bg-danger/15 text-danger border-danger/30'
}
>
{record.status === 'success' ? '成功' : '失败'}
</Badge>
<span className="text-sm text-muted-foreground">{record.message}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(record.timestamp).toLocaleString('zh-CN')}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client"
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Settings, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import api from '@/lib/api';
import type { Repository } from '@/services/repositoryService';
interface RepositoryConfigCellProps {
repo: Repository;
}
export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
const queryClient = useQueryClient();
const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
const promptMutation = useMutation({
mutationFn: async (prompt: string) => {
const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
project_review_prompt: prompt,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories'] });
setIsPromptDialogOpen(false);
toast.success(`已更新 ${repo.name} 的项目级提示词`);
},
onError: (error: Error) => {
toast.error(`更新失败: ${error.message}`);
},
});
const handleSavePrompt = () => {
promptMutation.mutate(draftPrompt.trim());
};
const handleOpenDialog = () => {
setDraftPrompt(repo.project_review_prompt ?? '');
setIsPromptDialogOpen(true);
};
const hasPrompt = !!repo.project_review_prompt?.trim();
return (
<>
<Button
variant={hasPrompt ? "outline" : "ghost"}
size="sm"
className={`h-8 gap-1.5 text-xs ${
hasPrompt
? "border-primary/50 text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={handleOpenDialog}
>
<Settings className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{hasPrompt ? '已配置' : '配置'}</span>
{hasPrompt && <span className="ml-1 h-1.5 w-1.5 rounded-full bg-primary" />}
</Button>
<Dialog open={isPromptDialogOpen} onOpenChange={setIsPromptDialogOpen}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{repo.name}</code>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{hasPrompt && (
<div className="rounded-lg bg-muted/50 border border-border/50 p-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap break-all max-h-[80px] overflow-y-auto">
{repo.project_review_prompt}
</p>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
value={draftPrompt}
onChange={(e) => setDraftPrompt(e.target.value)}
placeholder="输入项目级审查提示词,例如:重点关注 API 安全性、空值处理和错误边界..."
className="min-h-[120px] resize-none text-sm leading-relaxed focus-visible:ring-1 focus-visible:ring-primary/50"
disabled={promptMutation.isPending}
/>
<p className="text-xs text-muted-foreground">
AI
{hasPrompt && ' 留空保存将清除当前配置。'}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsPromptDialogOpen(false)}
disabled={promptMutation.isPending}
>
</Button>
<Button
onClick={handleSavePrompt}
disabled={
promptMutation.isPending ||
draftPrompt.trim() === (repo.project_review_prompt ?? '').trim()
}
>
{promptMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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-[50%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
<TableHead className="w-[25%]"><Skeleton className="h-5 w-16 bg-muted" /></TableHead>
<TableHead className="w-[25%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i} className="border-border/50">
<TableCell><Skeleton className="h-5 w-3/4 bg-zinc-800/80" /></TableCell>
<TableCell><Skeleton className="h-6 w-20 bg-zinc-800/80 rounded-full" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-zinc-800/80" /></TableCell>
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
<TableCell><Skeleton className="h-5 w-20 bg-muted/70" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-8 w-24 ml-auto bg-muted/70" /></TableCell>
</TableRow>
))}
</TableBody>
@@ -60,31 +60,33 @@ export function RepositoryManager() {
const totalPages = Math.ceil(totalCount / limit);
return (
<div>
<div className="flex justify-end mb-6">
<div className="relative w-full md:w-auto">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
<div className="space-y-6">
<div className="theme-card-shell p-4 md:p-5">
<div className="flex justify-end">
<div className="relative w-full md:w-auto">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="搜索仓库..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] bg-zinc-900/50 border-border/50 text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary transition-all font-mono text-sm"
className="theme-input-surface pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] focus-visible:ring-1 transition-all font-mono text-sm"
/>
</div>
</div>
</div>
{isLoading ? (
<DataTableSkeleton />
) : isError ? (
<div className="p-6 rounded-xl border border-rose-500/20 bg-rose-500/5 glass-panel">
<div className="flex items-start gap-3">
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
<div className="theme-error-panel p-6">
<div className="flex items-start gap-3">
<div className="p-2 bg-danger/10 rounded-lg text-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div className="space-y-1">
<h3 className="font-mono text-sm font-medium text-rose-500">System Error_</h3>
<p className="font-mono text-xs text-rose-400/80">: {error.message}</p>
<h3 className="font-mono text-sm font-medium text-danger">System Error_</h3>
<p className="font-mono text-xs text-danger/80">: {error.message}</p>
</div>
</div>
</div>
@@ -93,8 +95,8 @@ export function RepositoryManager() {
<DataTable columns={columns} data={repos} />
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between w-full mt-6 gap-4">
<div className="font-mono text-xs text-zinc-400 flex-shrink-0 bg-zinc-900/50 px-3 py-1.5 rounded-md border border-white/5">
<span className="text-zinc-200">{page}</span> / <span className="text-zinc-200">{totalPages}</span> <span className="text-zinc-600 mx-1">|</span> <span className="text-zinc-200">{totalCount}</span>
<div className="theme-control-pill font-mono flex-shrink-0 rounded-md">
<span className="text-foreground">{page}</span> / <span className="text-foreground">{totalPages}</span> <span className="text-muted-foreground/70 mx-1">|</span> <span className="text-foreground">{totalCount}</span>
</div>
<Pagination className="flex-shrink-0 w-auto mx-0">
<PaginationContent className="gap-2">
@@ -105,7 +107,7 @@ export function RepositoryManager() {
e.preventDefault();
setPage(p => Math.max(1, p - 1));
}}
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`}
/>
</PaginationItem>
<PaginationItem>
@@ -115,7 +117,7 @@ export function RepositoryManager() {
e.preventDefault();
setPage(p => Math.min(totalPages, p + 1));
}}
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`}
/>
</PaginationItem>
</PaginationContent>

View File

@@ -2,42 +2,34 @@
import type { ColumnDef } from "@tanstack/react-table"
import type { Repository } from "@/services/repositoryService"
import { WebhookToggleButton } from "@/components/WebhookToggleButton"
import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
import { WebhookToggleCell } from "@/components/WebhookToggleCell"
export const columns: ColumnDef<Repository>[] = [
{
accessorKey: "name",
header: "仓库名称",
cell: ({ row }) => <div className="font-medium text-zinc-100 text-sm">{row.getValue("name")}</div>,
cell: ({ row }) => (
<div className="font-medium text-foreground text-sm">
{row.getValue("name")}
</div>
),
},
{
accessorKey: "webhook_status",
header: "Webhook 状态",
header: "Webhook",
cell: ({ row }) => {
const status = row.getValue("webhook_status") as Repository["webhook_status"]
const isActive = status === 'active'
return (
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-transparent text-zinc-500 border-zinc-700'}`}>
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" style={{ boxShadow: '0 0 8px 1px rgba(52, 211, 153, 0.6)' }}></span>}
{isActive ? '已启用' : '未启用'}
</div>
)
const repo = row.original
return <WebhookToggleCell repo={repo} />
},
},
{
id: "actions",
header: () => <div className="text-right text-zinc-400"></div>,
cell: ({ row }) => {
const repo = row.original
return (
<div className="text-right">
<WebhookToggleButton
repoName={repo.name}
status={repo.webhook_status}
hookId={repo.hook_id}
/>
</div>
)
},
header: () => <div className="text-right text-muted-foreground text-xs"></div>,
cell: ({ row }) => (
<div className="text-right">
<RepositoryConfigCell repo={row.original} />
</div>
),
},
]

View File

@@ -0,0 +1,367 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchConfig, updateConfig, resetConfig } from '@/services/configService';
import type { ConfigResponse, ConfigGroupDto, ConfigFieldDto } from '@/services/configService';
import { ConfigGroupCard } from './ConfigGroupCard';
import { ModelCombobox } from './llm/ModelCombobox';
import { ProviderList } from './llm/ProviderList';
import { RoleAssignment } from './llm/RoleAssignment';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Save, AlertCircle, RotateCcw, Layers } from 'lucide-react';
import { toast } from 'sonner';
// ---------------------------------------------------------------------------
// Engine-specific field visibility
// ---------------------------------------------------------------------------
type EngineMode = 'agent' | 'codex';
/** The engine selector field — always visible at the top. */
const ENGINE_FIELD = 'REVIEW_ENGINE';
const AGENT_SHARED_FIELDS = new Set([
'GLOBAL_PROMPT',
'REVIEW_WORKDIR',
'REVIEW_MAX_PARALLEL_RUNS',
'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS',
]);
/** Fields specific to agent mode only. */
const AGENT_ONLY_FIELDS = new Set([
'REVIEW_ALLOWED_COMMANDS',
'REVIEW_COMMAND_TIMEOUT_MS',
'LLM_MAX_CONCURRENT_CALLS',
'LLM_RETRY_MAX_ATTEMPTS',
'LLM_RETRY_BASE_DELAY_MS',
]);
/** Fields specific to codex mode only. */
const CODEX_FIELDS = new Set([
'CODEX_API_URL',
'CODEX_API_KEY',
'CODEX_MODEL',
'CODEX_TIMEOUT_MS',
'CODEX_REVIEW_PROMPT',
'REVIEW_WORKDIR',
'REVIEW_MAX_PARALLEL_RUNS',
'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS',
]);
/** Field rendered with ModelCombobox instead of plain input. */
const CODEX_MODEL_FIELD = 'CODEX_MODEL';
function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigFieldDto[] {
return fields.filter((f) => {
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
switch (engine) {
case 'agent':
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
case 'codex':
return CODEX_FIELDS.has(f.envKey);
default:
return false;
}
});
}
// ---------------------------------------------------------------------------
// Engine selector badges
// ---------------------------------------------------------------------------
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ReviewConfigPage() {
const queryClient = useQueryClient();
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
const [hasChanges, setHasChanges] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
// Derived: current engine mode
const engine: EngineMode = useMemo(() => {
const val = localConfig[ENGINE_FIELD];
if (val === 'agent' || val === 'codex') return val;
return 'agent';
}, [localConfig]);
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
data.groups
.filter((g) => g.key === 'review')
.forEach((group) => {
group.fields.forEach((field) => {
if (field.sensitive && field.hasValue) {
initialState[field.envKey] = '••••••••';
} else if (field.type === 'boolean') {
initialState[field.envKey] = field.value === 'true' || field.value === true;
} else {
initialState[field.envKey] = field.value ?? '';
}
});
});
setLocalConfig(initialState);
setHasChanges(false);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
onSuccess: () => {
toast.success('审查配置已保存');
queryClient.invalidateQueries({ queryKey: ['config'] });
setHasChanges(false);
},
onError: (err: Error) => {
toast.error(`保存失败: ${err.message}`);
},
});
const resetMutation = useMutation({
mutationFn: (keys: string[]) => resetConfig(keys),
onSuccess: () => {
toast.success('配置已重置');
queryClient.invalidateQueries({ queryKey: ['config'] });
},
onError: (err: Error) => {
toast.error(`重置失败: ${err.message}`);
},
});
const handleFieldChange = (envKey: string, value: any) => {
setLocalConfig((prev) => ({ ...prev, [envKey]: value }));
setHasChanges(true);
};
const handleSave = () => {
const payload: Record<string, string> = {};
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
for (const key of fieldsToSave) {
const val = localConfig[key];
if (typeof val === 'boolean') {
payload[key] = val ? 'true' : 'false';
} else {
payload[key] = val === undefined || val === null ? '' : String(val);
}
}
saveMutation.mutate(payload);
};
const handleResetGroup = (keys: string[]) => {
if (confirm('确定要重置这些配置到默认值吗?这将立即生效并重载关联设置。')) {
resetMutation.mutate(keys);
}
};
const handleResetAll = () => {
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
const allOverrideKeys = groups
.flatMap((g) => g.fields)
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
if (allOverrideKeys.length === 0) return;
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
resetMutation.mutate(allOverrideKeys);
}
};
// Derived: visible fields for the current engine
const visibleReviewFields = useMemo(
() => (reviewGroup ? getVisibleFields(engine, reviewGroup.fields) : []),
[engine, reviewGroup]
);
const hasOverrides = useMemo(() => {
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
}, [reviewGroup]);
// -- Render states --
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-10 w-48 bg-muted/60" />
<Skeleton className="h-10 w-24 bg-muted/60" />
</div>
<Skeleton className="h-[200px] w-full rounded-xl bg-muted/60 border border-border/60" />
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
</div>
);
}
if (isError) {
return (
<div className="theme-error-panel flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
);
}
// Build a synthetic group for the visible review fields
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
? {
...reviewGroup,
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
description:
engine === 'codex'
? 'Codex CLI 审查引擎配置'
: 'Agent 审查引擎配置',
fields: visibleReviewFields,
}
: null;
/** Custom field renderer: CODEX_MODEL uses ModelCombobox for tokenlens suggestions. */
const renderReviewField = engine === 'codex'
? (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => {
if (field.envKey !== CODEX_MODEL_FIELD) return undefined;
// Replicate ConfigFieldInput layout with ModelCombobox as the input control
const sourceBadge = field.source === 'db'
? <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-accent hover:text-foreground hover:border-border/70 transition-colors"></Badge>
: <Badge variant="outline" className="ml-2 border-border text-muted-foreground"></Badge>;
return (
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-accent/40 transition-colors rounded-lg">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex flex-col space-y-1.5 flex-1">
<div className="flex items-center">
<label className="text-base font-semibold text-foreground">{field.label || field.envKey}</label>
{sourceBadge}
</div>
<div className="text-sm text-muted-foreground leading-relaxed">{field.description}</div>
<div className="pt-1">
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
{field.envKey}
</span>
</div>
</div>
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
<ModelCombobox
providerType="openai_compatible"
value={value ?? ''}
onChange={onChange}
placeholder="选择或输入模型..."
/>
</div>
</div>
</div>
);
}
: undefined;
return (
<div className="theme-page-frame">
{/* Sticky action bar */}
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
<div className="theme-page-actions">
<Button
variant="outline"
onClick={handleResetAll}
disabled={!hasOverrides || resetMutation.isPending}
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
>
{saveMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> ...
</span>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
<div className="theme-page-content">
{/* Engine Selector Card */}
<Card className="gap-0 py-0 theme-card-shell group">
<CardHeader className="theme-card-header pb-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
<Layers className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-foreground tracking-tight"></CardTitle>
<CardDescription className="text-muted-foreground"></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="theme-card-content">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ENGINE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleFieldChange(ENGINE_FIELD, opt.value)}
className={`relative flex flex-col items-start gap-2 rounded-xl border p-4 text-left transition-all duration-200 ${
engine === opt.value
? 'border-primary/50 bg-primary/10 theme-glow-primary'
: 'border-border bg-muted/30 hover:bg-muted/50 hover:border-border'
}`}
>
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-foreground">{opt.label}</span>
{engine === opt.value && (
<Badge className="bg-primary/20 text-primary border-primary/30 text-xs"></Badge>
)}
</div>
<span className="text-sm text-muted-foreground">{opt.description}</span>
{engine === opt.value && (
<div className="absolute top-0 right-0 w-3 h-3 m-2 rounded-full bg-primary theme-glow-primary" />
)}
</button>
))}
</div>
</CardContent>
</Card>
{/* Engine-specific review config fields */}
{syntheticReviewGroup && syntheticReviewGroup.fields.length > 0 && (
<ConfigGroupCard
group={syntheticReviewGroup}
localConfig={localConfig}
onFieldChange={handleFieldChange}
onReset={handleResetGroup}
isResetting={resetMutation.isPending}
renderField={renderReviewField}
/>
)}
{engine !== 'codex' && (
<>
<ProviderList />
<RoleAssignment />
</>
)}
</div>
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from "sonner";
interface WebhookToggleButtonProps {
repoName: string;
status: 'active' | 'inactive';
hookId: number | null;
}
const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: status === 'active'
? () => deleteWebhook({ repoName, hookId: hookId! })
: () => createWebhook(repoName),
onSuccess: () => {
// 操作成功后使仓库列表的查询失效React Query会自动重新获取最新数据
queryClient.invalidateQueries({ queryKey: ['repositories'] });
toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
},
onError: (error) => {
console.error("操作失败:", error);
toast.error(`Operation failed: ${error.message}`);
},
});
return (
<Button
variant={status === 'active' ? 'outline' : 'default'}
size="sm"
className={
status === 'active'
? "border-rose-500/50 bg-transparent text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 transition-colors"
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 hover:shadow-[0_0_15px_rgba(45,212,191,0.5)] tech-glow"
}
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
<span className="font-mono text-xs">...</span>
</>
) : status === 'active' ? (
<span className="font-mono text-xs"></span>
) : (
<span className="font-mono text-xs"></span>
)}
</Button>
);
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
import api from '@/lib/api';
import type { Repository } from '@/services/repositoryService';
interface WebhookToggleCellProps {
repo: Repository;
}
const createWebhook = (repoName: string) =>
api.post(`/repositories/${repoName}/webhook`);
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
api.delete(`/repositories/${repoName}/webhook/${hookId}`);
export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
const queryClient = useQueryClient();
const isActive = repo.webhook_status === 'active';
const webhookMutation = useMutation({
mutationFn: async () => {
if (isActive && repo.hook_id) {
return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
}
return createWebhook(repo.name);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories'] });
const action = isActive ? '已禁用' : '已启用';
toast.success(`${repo.name} 的 Webhook ${action}`);
},
onError: (error: Error) => {
toast.error(`操作失败: ${error.message}`);
},
});
return (
<div className="flex items-center gap-2">
{webhookMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={isActive}
onCheckedChange={() => webhookMutation.mutate()}
disabled={webhookMutation.isPending}
aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
/>
)}
<span className={`text-xs ${isActive ? 'text-success' : 'text-muted-foreground'}`}>
{isActive ? '已启用' : '未启用'}
</span>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RepositoryConfigCell } from '../RepositoryConfigCell';
import type { Repository } from '@/services/repositoryService';
const apiMocks = vi.hoisted(() => ({
put: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
default: apiMocks,
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
name: 'demo-owner/demo-repo',
webhook_status: 'inactive',
hook_id: null,
project_review_prompt: null,
...overrides,
};
}
describe('RepositoryConfigCell', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('opens prompt dialog and saves project prompt', async () => {
apiMocks.put.mockResolvedValueOnce({
data: {
success: true,
project_review_prompt: 'focus null safety',
},
});
const user = userEvent.setup();
renderWithQuery(<RepositoryConfigCell repo={makeRepo()} />);
await user.click(screen.getByRole('button', { name: /配置/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
const textarea = screen.getByRole('textbox');
await user.type(textarea, ' focus null safety ');
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(apiMocks.put).toHaveBeenCalledWith(
'/repositories/demo-owner/demo-repo/project-prompt',
{ project_review_prompt: 'focus null safety' }
);
});
});
});

View File

@@ -0,0 +1,185 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ReviewConfigPage } from '../ReviewConfigPage';
import { fetchConfig, updateConfig, resetConfig, type ConfigResponse } from '@/services/configService';
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/services/configService', () => ({
fetchConfig: vi.fn(),
updateConfig: vi.fn(),
resetConfig: vi.fn(),
}));
vi.mock('../llm/ProviderList', () => ({
ProviderList: () => <div>ProviderListMock</div>,
}));
vi.mock('../llm/RoleAssignment', () => ({
RoleAssignment: () => <div>RoleAssignmentMock</div>,
}));
vi.mock('../llm/ModelCombobox', () => ({
ModelCombobox: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<input aria-label="Codex model" value={value} onChange={(event) => onChange(event.target.value)} />
),
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeConfigResponse(): ConfigResponse {
return {
groups: [
{
key: 'review',
label: '审查引擎',
description: 'Agent 审查模式、并发与沙箱设置',
icon: 'file-check',
fields: [
{
envKey: 'REVIEW_ENGINE',
label: '审查引擎',
description: '代码审查模式',
type: 'enum',
sensitive: false,
enumValues: ['agent', 'codex'],
value: 'agent',
hasValue: true,
source: 'db',
},
{
envKey: 'GLOBAL_PROMPT',
label: '全局提示词',
description: '附加到所有 LLM 调用',
type: 'text',
sensitive: false,
value: '',
hasValue: false,
source: 'default',
},
{
envKey: 'REVIEW_WORKDIR',
label: '工作目录',
description: 'Agent 模式下本地仓库目录',
type: 'string',
sensitive: false,
value: '/tmp/gitea-assistant',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
label: '最大并发数',
description: '单机同时执行的审查任务上限',
type: 'number',
sensitive: false,
value: '2',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_ALLOWED_COMMANDS',
label: '允许命令',
description: '本地审查沙箱命令白名单',
type: 'string',
sensitive: false,
value: 'git,rg,cat,sed,wc',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
label: '命令超时(ms)',
description: '单条本地命令的执行超时时间',
type: 'number',
sensitive: false,
min: 120000,
max: 300000,
value: '120000',
hasValue: true,
source: 'db',
},
{
envKey: 'LLM_MAX_CONCURRENT_CALLS',
label: 'LLM 最大并发调用',
description: '同时在飞的 LLM API 调用上限',
type: 'number',
sensitive: false,
value: '4',
hasValue: true,
source: 'db',
},
{
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
label: 'Large 令牌预算',
description: 'large 规模审查任务的 token 预算上限',
type: 'number',
sensitive: false,
value: '120000',
hasValue: true,
source: 'db',
},
{
envKey: 'CODEX_MODEL',
label: 'Codex 模型',
description: 'Codex CLI 使用的模型名称',
type: 'string',
sensitive: false,
value: 'o3',
hasValue: true,
source: 'db',
},
],
},
],
};
}
describe('ReviewConfigPage', () => {
it('shows only current Agent config surface and saves only visible fields', async () => {
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
vi.mocked(updateConfig).mockResolvedValue(undefined);
vi.mocked(resetConfig).mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithQuery(<ReviewConfigPage />);
expect(await screen.findByText('Agent 审查设置')).toBeInTheDocument();
expect(screen.getByText('REVIEW_COMMAND_TIMEOUT_MS')).toBeInTheDocument();
expect(screen.queryByText('REVIEW_TOKEN_BUDGET_LARGE')).not.toBeInTheDocument();
expect(screen.queryByText('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE')).not.toBeInTheDocument();
expect(screen.queryByText('REVIEW_ENABLE_HUMAN_GATE')).not.toBeInTheDocument();
expect(screen.queryByText('ENABLE_TRIAGE')).not.toBeInTheDocument();
const workdirInput = screen.getByDisplayValue('/tmp/gitea-assistant');
await user.clear(workdirInput);
await user.type(workdirInput, '/tmp/new-review-workdir');
await user.click(screen.getByRole('button', { name: '保存配置' }));
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
const payload = vi.mocked(updateConfig).mock.calls[0][0];
expect(payload.REVIEW_WORKDIR).toBe('/tmp/new-review-workdir');
expect(payload.REVIEW_ENGINE).toBe('agent');
expect(payload.REVIEW_COMMAND_TIMEOUT_MS).toBe('120000');
expect(payload).not.toHaveProperty('REVIEW_TOKEN_BUDGET_LARGE');
expect(payload).not.toHaveProperty('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE');
expect(payload).not.toHaveProperty('REVIEW_ENABLE_HUMAN_GATE');
expect(payload).not.toHaveProperty('ENABLE_TRIAGE');
});
});

View File

@@ -0,0 +1,84 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WebhookToggleCell } from '../WebhookToggleCell';
import type { Repository } from '@/services/repositoryService';
const apiMocks = vi.hoisted(() => ({
post: vi.fn(),
delete: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
default: apiMocks,
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
name: 'demo-owner/demo-repo',
webhook_status: 'inactive',
hook_id: null,
project_review_prompt: null,
...overrides,
};
}
describe('WebhookToggleCell', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('toggles webhook via switch to enable', async () => {
apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
const user = userEvent.setup();
renderWithQuery(<WebhookToggleCell repo={makeRepo()} />);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
expect(screen.getByText('未启用')).toBeInTheDocument();
await user.click(switchEl);
await waitFor(() => {
expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
});
});
it('toggles webhook via switch to disable', async () => {
apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
const user = userEvent.setup();
renderWithQuery(<WebhookToggleCell repo={makeRepo({ webhook_status: 'active', hook_id: 123 })} />);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('已启用')).toBeInTheDocument();
await user.click(switchEl);
await waitFor(() => {
expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
});
});
});

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,250 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
import { fetchProviders, updateProvider, deleteProvider, testProvider } from '@/services/llmProviderService';
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
import { ProviderDialog } from './ProviderDialog';
import { TestResultDialog } from './TestResultDialog';
const TYPE_LABELS: Record<string, string> = {
openai_compatible: 'OpenAI 兼容',
openai_responses: 'OpenAI Responses',
anthropic: 'Anthropic',
gemini: 'Gemini',
};
const TYPE_COLORS: Record<string, string> = {
openai_compatible: 'bg-success/10 text-success border-success/20',
openai_responses: 'bg-info/10 text-info border-info/20',
anthropic: 'bg-warning/10 text-warning border-warning/20',
gemini: 'bg-primary/10 text-primary border-primary/20',
};
export function ProviderList() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<ProviderDto | undefined>(undefined);
const [testingId, setTestingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testProviderName, setTestProviderName] = useState<string>('');
const { data: providers = [], isLoading } = useQuery({
queryKey: ['llm-providers'],
queryFn: fetchProviders,
});
const toggleMutation = useMutation({
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
return updateProvider(id, { isEnabled });
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['llm-providers'] });
const previousProviders = queryClient.getQueryData<ProviderDto[]>(['llm-providers']);
queryClient.setQueryData<ProviderDto[]>(['llm-providers'], old =>
old?.map(p => p.id === variables.id ? { ...p, isEnabled: variables.isEnabled } : p)
);
return { previousProviders };
},
onError: (_err, _variables, context) => {
queryClient.setQueryData(['llm-providers'], context?.previousProviders);
toast.error('切换状态失败');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
toast.success('已删除提供商');
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error(`删除失败: ${err?.response?.data?.error || err.message}`);
}
});
const handleToggle = (provider: ProviderDto) => {
toggleMutation.mutate({ id: provider.id, isEnabled: !provider.isEnabled });
};
const handleDelete = (provider: ProviderDto) => {
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
return;
}
deleteMutation.mutate(provider.id);
};
const handleTest = async (provider: ProviderDto) => {
try {
setTestingId(provider.id);
const result = await testProvider(provider.id);
setTestResult(result);
setTestProviderName(provider.name);
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error('测试请求失败', {
description: err?.response?.data?.error || err.message
});
} finally {
setTestingId(null);
}
};
const openAdd = () => {
setEditingProvider(undefined);
setDialogOpen(true);
};
const openEdit = (provider: ProviderDto) => {
setEditingProvider(provider);
setDialogOpen(true);
};
return (
<>
<Card className="gap-0 py-0 theme-card-shell group">
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
<Activity className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
</CardTitle>
<CardDescription className="text-muted-foreground">
LLM API 访
</CardDescription>
</div>
</div>
<Button onClick={openAdd} className="bg-primary text-primary-foreground hover:bg-primary/90 theme-glow-primary transition-all">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="border-border/60 hover:bg-transparent">
<TableHead className="text-muted-foreground font-medium h-12"></TableHead>
<TableHead className="text-muted-foreground font-medium h-12"></TableHead>
<TableHead className="text-muted-foreground font-medium h-12"></TableHead>
<TableHead className="text-muted-foreground font-medium h-12 text-center"></TableHead>
<TableHead className="text-muted-foreground font-medium h-12 text-center"></TableHead>
<TableHead className="text-muted-foreground font-medium h-12 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow className="border-border/60 hover:bg-muted/30">
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
...
</div>
</TableCell>
</TableRow>
) : providers.length === 0 ? (
<TableRow className="border-border/60 hover:bg-muted/30">
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
providers.map(provider => (
<TableRow key={provider.id} className="border-border/60 hover:bg-muted/30 transition-colors">
<TableCell className="font-medium text-foreground">
{provider.name}
</TableCell>
<TableCell>
<Badge variant="outline" className={`font-normal ${TYPE_COLORS[provider.type] || 'text-muted-foreground'}`}>
{TYPE_LABELS[provider.type] || provider.type}
</Badge>
</TableCell>
<TableCell className="text-foreground/90">
<code className="bg-muted/60 px-1.5 py-0.5 rounded text-xs text-primary/80">
{provider.defaultModel}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1.5" title={provider.hasKey ? '已配置 API Key' : '未配置 API Key'}>
<span className={`w-2 h-2 rounded-full ${provider.hasKey ? 'bg-success theme-glow-success' : 'bg-muted-foreground/60'}`} />
<span className="text-xs text-muted-foreground">{provider.hasKey ? '就绪' : '无 Key'}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
checked={provider.isEnabled}
onCheckedChange={() => handleToggle(provider)}
className="data-[state=checked]:bg-primary"
/>
</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleTest(provider)}
disabled={testingId === provider.id || !provider.hasKey}
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="测试连接"
>
{testingId === provider.id ? (
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(provider)}
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(provider)}
className="h-8 w-8 text-muted-foreground hover:text-danger hover:bg-danger/10 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<ProviderDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
provider={editingProvider}
/>
<TestResultDialog
open={!!testResult}
onOpenChange={(open) => !open && setTestResult(null)}
result={testResult}
providerName={testProviderName}
/>
</>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchConfig, updateConfig } from '@/services/configService';
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { AlertCircle, Save, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export function RoleAssignment() {
const queryClient = useQueryClient();
const [localValues, setLocalValues] = useState<Record<string, string>>({});
const [isDirty, setIsDirty] = useState(false);
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
});
const REQUIRED_KEYS = [
'AGENT_MAIN_MODEL',
'AGENT_DEFAULT_SUBAGENT_MODEL',
'LLM_MAX_CONCURRENT_CALLS',
'LLM_RETRY_MAX_ATTEMPTS',
'LLM_RETRY_BASE_DELAY_MS',
];
const fieldsMap = useMemo(() => {
if (!data) return new Map<string, ConfigFieldDto>();
const map = new Map<string, ConfigFieldDto>();
data.groups.forEach((group) => {
group.fields.forEach((field) => {
map.set(field.envKey, field);
});
});
return map;
}, [data]);
useEffect(() => {
if (data) {
const initialValues: Record<string, string> = {};
REQUIRED_KEYS.forEach((key) => {
const field = fieldsMap.get(key);
if (field) {
initialValues[key] = String(field.value ?? field.defaultValue ?? '');
} else {
initialValues[key] = '';
}
});
setLocalValues(initialValues);
setIsDirty(false);
}
}, [data, fieldsMap]);
const saveMutation = useMutation({
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
onSuccess: () => {
toast.success('智能体模型设置已保存');
queryClient.invalidateQueries({ queryKey: ['config'] });
setIsDirty(false);
},
onError: (err: Error) => {
toast.error(`保存失败: ${err.message}`);
},
});
const handleFieldChange = (key: string, value: string) => {
setLocalValues((prev) => ({ ...prev, [key]: value }));
setIsDirty(true);
};
const handleSave = () => {
const payload: Record<string, string> = {};
REQUIRED_KEYS.forEach((key) => {
payload[key] = localValues[key] ?? '';
});
saveMutation.mutate(payload);
};
if (isLoading) {
return (
<Card className="gap-0 py-0 theme-card-shell group">
<CardHeader className="theme-card-header pb-4">
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
</CardTitle>
<CardDescription className="text-muted-foreground">
...
</CardDescription>
</CardHeader>
<CardContent className="theme-card-content flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card className="gap-0 py-0 theme-card-shell group">
<CardHeader className="theme-card-header pb-4">
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
</CardTitle>
</CardHeader>
<CardContent className="theme-card-content">
<div className="theme-error-panel flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<div className="font-medium tracking-wide">: {error.message}</div>
</div>
</CardContent>
</Card>
);
}
const missingKeys = REQUIRED_KEYS.filter((key) => !fieldsMap.has(key));
return (
<Card className="gap-0 py-0 theme-card-shell group">
<CardHeader className="theme-card-header pb-4 flex flex-row items-start justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
</CardTitle>
<CardDescription className="text-muted-foreground">
LLM
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={!isDirty || saveMutation.isPending}
className="theme-interactive-elevate min-w-[100px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
>
{saveMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> ...
</span>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</CardHeader>
<CardContent className="theme-card-content space-y-6">
{missingKeys.length > 0 && (
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-warning text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div>
<span className="font-semibold"></span>
<span className="font-mono text-xs">{missingKeys.join(', ')}</span>
</div>
</div>
)}
<div className="space-y-4">
{REQUIRED_KEYS.map((key) => {
const field = fieldsMap.get(key);
const isAvailable = !!field;
const label = field?.label || key;
const description = field?.description || '系统未提供该配置项的描述。';
const type = field?.type === 'number' ? 'number' : 'text';
return (
<div
key={key}
className={`flex flex-col gap-2 p-4 rounded-lg border transition-colors ${
isAvailable
? 'border-border hover:bg-accent/20'
: 'border-dashed border-muted bg-muted/10 opacity-60'
}`}
>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center gap-2">
<Label htmlFor={key} className="text-base font-semibold text-foreground cursor-pointer">
{label}
</Label>
{!isAvailable && (
<Badge variant="outline" className="border-danger/30 text-danger bg-danger/5">
</Badge>
)}
{isAvailable && field.source === 'db' && (
<Badge className="bg-primary/20 text-primary border-primary/30 tech-glow">
</Badge>
)}
{isAvailable && field.source === 'default' && (
<Badge variant="outline" className="border-border text-muted-foreground">
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground leading-relaxed">
{description}
</span>
<div className="pt-1">
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
{key}
</span>
</div>
</div>
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
<Input
id={key}
type={type}
value={localValues[key] ?? ''}
onChange={(e) => handleFieldChange(key, e.target.value)}
disabled={!isAvailable || saveMutation.isPending}
placeholder={!isAvailable ? '配置项不可用' : `请输入 ${label}...`}
className="bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"
/>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

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,87 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ProviderList } from '../ProviderList';
import {
fetchProviders,
updateProvider,
deleteProvider,
testProvider,
} from '@/services/llmProviderService';
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/services/llmProviderService', () => ({
fetchProviders: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
testProvider: vi.fn(),
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe('ProviderList', () => {
it('renders providers, enable states and hasKey indicators', async () => {
vi.mocked(fetchProviders).mockResolvedValueOnce([
{
id: 'p1',
name: 'OpenAI 官方',
type: 'openai_responses',
baseUrl: null,
defaultModel: 'gpt-4o',
isEnabled: true,
hasKey: true,
extraConfig: {},
createdAt: '2026-01-01',
},
{
id: 'p2',
name: '本地兼容服务',
type: 'openai_compatible',
baseUrl: 'https://example.com/v1',
defaultModel: 'qwen-plus',
isEnabled: false,
hasKey: false,
extraConfig: {},
createdAt: '2026-01-01',
},
]);
vi.mocked(updateProvider).mockResolvedValue({} as never);
vi.mocked(deleteProvider).mockResolvedValue(undefined);
vi.mocked(testProvider).mockResolvedValue({ success: true });
renderWithQuery(<ProviderList />);
expect(await screen.findByText('模型提供商')).toBeInTheDocument();
expect(await screen.findByText('OpenAI 官方')).toBeInTheDocument();
expect(await screen.findByText('本地兼容服务')).toBeInTheDocument();
expect(screen.getByText('OpenAI Responses')).toBeInTheDocument();
expect(screen.getByText('OpenAI 兼容')).toBeInTheDocument();
expect(screen.getByText('就绪')).toBeInTheDocument();
expect(screen.getByText('无 Key')).toBeInTheDocument();
const switches = screen.getAllByRole('switch');
expect(switches).toHaveLength(2);
expect(switches[0]).toHaveAttribute('data-state', 'checked');
expect(switches[1]).toHaveAttribute('data-state', 'unchecked');
const testButtons = screen.getAllByTitle('测试连接');
expect(testButtons).toHaveLength(2);
expect(testButtons[0]).toBeEnabled();
expect(testButtons[1]).toBeDisabled();
});
});

View File

@@ -0,0 +1,190 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { RoleAssignment } from '../RoleAssignment';
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/services/configService', () => ({
fetchConfig: vi.fn(),
updateConfig: vi.fn(),
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeConfigResponse(): ConfigResponse {
return {
groups: [
{
key: 'llm',
label: 'LLM 设置',
description: 'LLM 运行时与弹性设置',
icon: 'brain',
fields: [
{
envKey: 'AGENT_MAIN_MODEL',
label: '主智能体模型',
description: '主智能体默认使用的模型名称',
type: 'string',
sensitive: false,
value: 'gpt-4o',
hasValue: true,
source: 'db',
},
{
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
label: '默认子智能体模型',
description: '子智能体默认使用的模型名称',
type: 'string',
sensitive: false,
value: 'gpt-4o-mini',
hasValue: true,
source: 'db',
},
{
envKey: 'LLM_MAX_CONCURRENT_CALLS',
label: 'LLM 最大并发调用',
description: '同时在飞的 LLM API 调用上限',
type: 'number',
sensitive: false,
value: '4',
hasValue: true,
source: 'db',
},
{
envKey: 'LLM_RETRY_MAX_ATTEMPTS',
label: 'LLM 最大重试次数',
description: 'LLM 调用失败时的最大重试次数',
type: 'number',
sensitive: false,
value: '3',
hasValue: true,
source: 'db',
},
{
envKey: 'LLM_RETRY_BASE_DELAY_MS',
label: 'LLM 重试基础延迟(ms)',
description: 'LLM 调用失败重试的基础延迟时间',
type: 'number',
sensitive: false,
value: '1000',
hasValue: true,
source: 'db',
},
],
},
],
};
}
describe('RoleAssignment', () => {
it('renders agent model settings and saves edits', async () => {
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
vi.mocked(updateConfig).mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithQuery(<RoleAssignment />);
// Wait for the fields to load and render
expect(await screen.findByText('主智能体模型')).toBeInTheDocument();
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
expect(screen.getByText('默认子智能体模型')).toBeInTheDocument();
expect(screen.getByText('LLM 最大并发调用')).toBeInTheDocument();
expect(screen.getByText('LLM 最大重试次数')).toBeInTheDocument();
expect(screen.getByText('LLM 重试基础延迟(ms)')).toBeInTheDocument();
const legacyLabels = ['pla' + 'nner', 'speci' + 'alist', 'ju' + 'dge', '角色' + '分配'];
legacyLabels.forEach((label) => {
expect(screen.queryByText(label)).not.toBeInTheDocument();
});
expect(screen.queryByText(['/', 'llm', '/', 'roles'].join(''))).not.toBeInTheDocument();
const mainModelInput = screen.getByLabelText('主智能体模型');
const subagentModelInput = screen.getByLabelText('默认子智能体模型');
const maxCallsInput = screen.getByLabelText('LLM 最大并发调用');
const retryAttemptsInput = screen.getByLabelText('LLM 最大重试次数');
const retryDelayInput = screen.getByLabelText('LLM 重试基础延迟(ms)');
await user.clear(mainModelInput);
await user.type(mainModelInput, 'claude-3-5-sonnet');
await user.clear(subagentModelInput);
await user.type(subagentModelInput, 'claude-3-5-haiku');
await user.clear(maxCallsInput);
await user.type(maxCallsInput, '8');
await user.clear(retryAttemptsInput);
await user.type(retryAttemptsInput, '5');
await user.clear(retryDelayInput);
await user.type(retryDelayInput, '2000');
const saveButton = screen.getByRole('button', { name: '保存设置' });
await user.click(saveButton);
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
const payload = vi.mocked(updateConfig).mock.calls[0][0];
expect(payload).toEqual({
AGENT_MAIN_MODEL: 'claude-3-5-sonnet',
AGENT_DEFAULT_SUBAGENT_MODEL: 'claude-3-5-haiku',
LLM_MAX_CONCURRENT_CALLS: '8',
LLM_RETRY_MAX_ATTEMPTS: '5',
LLM_RETRY_BASE_DELAY_MS: '2000',
});
});
it('renders missing-field/unavailable state when fields are missing', async () => {
vi.mocked(fetchConfig).mockResolvedValue({
groups: [
{
key: 'llm',
label: 'LLM 设置',
description: 'LLM 运行时与弹性设置',
icon: 'brain',
fields: [
{
envKey: 'AGENT_MAIN_MODEL',
label: '主智能体模型',
description: '主智能体默认使用的模型名称',
type: 'string',
sensitive: false,
value: 'gpt-4o',
hasValue: true,
source: 'db',
},
],
},
],
});
renderWithQuery(<RoleAssignment />);
// Wait for the warning to load and render
expect(await screen.findByText('部分配置项在系统中不可用:')).toBeInTheDocument();
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
expect(screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL')).toBeInTheDocument();
expect(screen.getByLabelText('LLM_MAX_CONCURRENT_CALLS')).toBeInTheDocument();
expect(screen.getByLabelText('LLM_RETRY_MAX_ATTEMPTS')).toBeInTheDocument();
expect(screen.getByLabelText('LLM_RETRY_BASE_DELAY_MS')).toBeInTheDocument();
const subagentInput = screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL');
expect(subagentInput).toBeDisabled();
});
});

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

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

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

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

@@ -0,0 +1,797 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
import type { AgentSessionTree } from '@/services/reviewSessionService';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
ChevronRight, ChevronDown, Clock, FileText, Layers,
AlertTriangle, CornerDownRight, HelpCircle, Info
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Helper Components & Formatters
// ---------------------------------------------------------------------------
function StatusBadge({ status }: { status: string }) {
switch (status) {
case 'succeeded':
case 'completed':
return <Badge className="bg-success/20 text-success border-success/30"></Badge>;
case 'failed':
return <Badge className="bg-danger/20 text-danger border-danger/30"></Badge>;
case 'running':
case 'in_progress':
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse"></Badge>;
case 'queued':
return <Badge className="bg-warning/20 text-warning border-warning/30"></Badge>;
case 'ignored':
return <Badge className="bg-muted text-muted-foreground border-border"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
}
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
switch (severity) {
case 'high':
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold"></Badge>;
case 'medium':
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold"></Badge>;
case 'low':
return <Badge className="bg-info/20 text-info border-info/30 font-bold"></Badge>;
default:
return <Badge variant="outline">{severity}</Badge>;
}
}
function formatDateTime(isoString?: string): string {
if (!isoString) return '-';
return new Date(isoString).toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// ---------------------------------------------------------------------------
// Agent Session Tree Node Component
// ---------------------------------------------------------------------------
interface TreeNodeProps {
session: AgentSessionTree;
level: number;
onSelectSession: (session: AgentSessionTree) => void;
selectedSessionId?: string;
}
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
const isSelected = selectedSessionId === session.id;
return (
<div className="flex flex-col w-full">
{/* Node Row */}
<div
onClick={() => onSelectSession(session)}
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
isSelected
? 'border-primary/50 bg-primary/10 theme-glow-primary'
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
}`}
style={{ marginLeft: `${level * 24}px` }}
>
<div className="flex items-center gap-3 min-w-0">
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
) : (
<div className="w-6 h-6 flex items-center justify-center">
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
</div>
)}
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
<Bot className="w-4 h-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="font-semibold text-sm text-foreground truncate">
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
</span>
<span className="text-xs text-muted-foreground font-mono truncate">
{session.model}
</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<StatusBadge status={session.status} />
{session.error && (
<div title="代理执行出错">
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
</div>
)}
</div>
</div>
{/* Children */}
{isExpanded && session.invocations && (
<div className="flex flex-col w-full">
{session.invocations.map((inv) => {
if (inv.childSession) {
return (
<AgentTreeNode
key={inv.childSession.id}
session={inv.childSession}
level={level + 1}
onSelectSession={onSelectSession}
selectedSessionId={selectedSessionId}
/>
);
} else if (inv.status === 'failed') {
// Failed subagent invocation without child session
return (
<div
key={inv.id}
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
style={{ marginLeft: `${(level + 1) * 24}px` }}
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-6 h-6 flex items-center justify-center">
<CornerDownRight className="w-4 h-4 text-danger/50" />
</div>
<div className="p-2 rounded-lg bg-danger/10 text-danger">
<Bot className="w-4 h-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="font-semibold text-sm text-danger truncate">
: {inv.agentType}
</span>
<span className="text-xs text-danger/80 font-mono truncate">
{inv.model}
</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<StatusBadge status="failed" />
</div>
</div>
);
}
return null;
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Agent Session Detail Panel Component
// ---------------------------------------------------------------------------
interface DetailPanelProps {
session: AgentSessionTree;
}
function AgentDetailPanel({ session }: DetailPanelProps) {
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
return (
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" />
{session.parentSessionId ? '子代理详情' : '主代理详情'}
</CardTitle>
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
ID: {session.id}
</CardDescription>
</div>
<StatusBadge status={session.status} />
</div>
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs"></span>
<span className="font-semibold text-foreground">{session.agentType}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs"></span>
<span className="font-mono font-semibold text-foreground">{session.model}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs"></span>
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs"></span>
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
</div>
</div>
{session.error && (
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1">
<div className="font-semibold"></div>
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
</pre>
</div>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
<div className="flex gap-2">
<Button
variant={activeTab === 'messages' ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab('messages')}
className="text-xs h-8"
>
<FileText className="w-3.5 h-3.5 mr-1.5" />
({session.messages?.length ?? 0})
</Button>
<Button
variant={activeTab === 'tools' ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab('tools')}
className="text-xs h-8"
>
<Terminal className="w-3.5 h-3.5 mr-1.5" />
({session.toolCalls?.length ?? 0})
</Button>
<Button
variant={activeTab === 'raw' ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab('raw')}
className="text-xs h-8"
>
<Info className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{activeTab === 'messages' && (
<div className="space-y-4">
{session.messages && session.messages.length > 0 ? (
session.messages.map((msg) => (
<div
key={msg.id}
className={`flex flex-col p-3 rounded-xl border ${
msg.role === 'user'
? 'border-primary/20 bg-primary/5 ml-8'
: msg.role === 'assistant'
? 'border-border bg-muted/40 mr-8'
: 'border-warning/20 bg-warning/5 mx-4'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className={`text-xs font-bold uppercase tracking-wider ${
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
}`}>
{msg.role}
</span>
<span className="text-[10px] text-muted-foreground">
{formatDateTime(msg.createdAt)}
</span>
</div>
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
{typeof msg.content === 'string'
? msg.content
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
? String(msg.content.text)
: JSON.stringify(msg.content, null, 2)}
</div>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
</div>
)}
</div>
)}
{activeTab === 'tools' && (
<div className="space-y-4">
{session.toolCalls && session.toolCalls.length > 0 ? (
session.toolCalls.map((tool) => (
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
<div className="flex items-center gap-2">
<Terminal className="w-3.5 h-3.5 text-primary" />
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
</div>
<StatusBadge status={tool.status} />
</div>
<div className="p-3 space-y-3 text-xs font-mono">
<div>
<div className="text-muted-foreground mb-1"> (Arguments)</div>
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div>
{tool.result !== undefined && (
<div>
<div className="text-muted-foreground mb-1"> (Result)</div>
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
</pre>
</div>
)}
{tool.error && (
<div>
<div className="text-danger mb-1"> (Error)</div>
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
</pre>
</div>
)}
</div>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
</div>
)}
</div>
)}
{activeTab === 'raw' && (
<div className="space-y-4 font-mono text-xs">
<div>
<div className="text-muted-foreground mb-1"> (Metadata)</div>
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(session.metadata, null, 2)}
</pre>
</div>
{session.finalResult !== undefined && (
<div>
<div className="text-muted-foreground mb-1"> (Final Result)</div>
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(session.finalResult, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page Component
// ---------------------------------------------------------------------------
export default function ReviewSessionsPage() {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
// Fetch runs list
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
queryKey: ['reviewRuns'],
queryFn: () => fetchReviewRuns(50),
});
// Fetch selected run details
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
queryKey: ['reviewRunDetails', selectedRunId],
queryFn: () => fetchReviewRunDetails(selectedRunId!),
enabled: !!selectedRunId,
});
const runs = runsData?.data ?? [];
// Handle run selection
const handleSelectRun = (runId: string) => {
setSelectedRunId(runId);
setSelectedSession(null); // Reset selected session when switching runs
};
// Automatically select first run if none selected
if (!selectedRunId && runs.length > 0) {
setSelectedRunId(runs[0].id);
}
// Automatically select root session when run details load
if (runDetails?.sessionTree && !selectedSession) {
setSelectedSession(runDetails.sessionTree);
}
return (
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar: Runs List */}
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
<div className="p-4 border-b border-border/50 shrink-0">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
</h2>
<p className="text-xs text-muted-foreground mt-1"> 50 </p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{isListLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
<Skeleton className="h-4 w-3/4 bg-muted/60" />
<Skeleton className="h-3 w-1/2 bg-muted/60" />
</div>
))
) : isListError ? (
<div className="theme-error-panel flex items-center gap-2 p-4">
<AlertCircle className="w-5 h-5 text-danger" />
<span className="text-sm font-medium">: {listError.message}</span>
</div>
) : runs.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
</div>
) : (
runs.map((run) => {
const isSelected = selectedRunId === run.id;
return (
<div
key={run.id}
onClick={() => handleSelectRun(run.id)}
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
isSelected
? 'border-primary/50 bg-primary/5 theme-glow-primary'
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-bold text-sm text-foreground truncate flex-1">
{run.owner}/{run.repo}
</span>
<StatusBadge status={run.status} />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
</Badge>
<span className="truncate font-mono text-[10px]">
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
</span>
<span>: {run.attempts}/{run.maxAttempts}</span>
</div>
</div>
);
})
)}
</div>
</aside>
{/* Right Content: Run Details */}
<main className="flex-1 flex flex-col overflow-hidden bg-background">
{selectedRunId ? (
isDetailsLoading ? (
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
<div className="space-y-2">
<Skeleton className="h-8 w-1/3 bg-muted/60" />
<Skeleton className="h-4 w-1/4 bg-muted/60" />
</div>
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
</div>
) : isDetailsError ? (
<div className="flex-1 flex items-center justify-center p-6">
<div className="theme-error-panel flex items-center gap-3 max-w-md">
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
<div>
<div className="font-bold text-foreground"></div>
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
</div>
</div>
</div>
) : !runDetails ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl font-bold text-foreground tracking-tight">
{runDetails.run.owner}/{runDetails.run.repo}
</h1>
<StatusBadge status={runDetails.run.status} />
<Badge variant="outline" className="border-border/60">
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono break-all">
ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
</p>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
<div className="flex flex-col items-end">
<span className="text-xs text-muted-foreground"></span>
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
</div>
{runDetails.run.finishedAt && (
<div className="flex flex-col items-end">
<span className="text-xs text-muted-foreground"></span>
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
</div>
)}
</div>
</div>
{runDetails.run.error && (
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<span className="font-semibold">:</span> {runDetails.run.error}
</div>
</div>
)}
</header>
{/* Detail Tabs */}
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
<TabsTrigger
value="observability"
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
>
(Observability)
</TabsTrigger>
<TabsTrigger
value="findings"
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
>
({runDetails.findings?.length ?? 0})
</TabsTrigger>
<TabsTrigger
value="log"
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
>
({runDetails.steps?.length ?? 0})
</TabsTrigger>
</TabsList>
</div>
{/* Tab Content: Observability */}
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
{runDetails.sessionTree ? (
<>
{/* Left: Session Tree */}
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
<Layers className="w-4 h-4" />
(Parent-Child Tree)
</h3>
<div className="space-y-2">
<AgentTreeNode
session={runDetails.sessionTree}
level={0}
onSelectSession={(session) => setSelectedSession(session)}
selectedSessionId={selectedSession?.id}
/>
</div>
</div>
{/* Right: Selected Session Detail */}
<div className="flex-1 h-full overflow-hidden">
{selectedSession ? (
<AgentDetailPanel session={selectedSession} />
) : (
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
<p className="text-sm font-medium"></p>
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium">使 Agent </p>
<p className="text-xs text-muted-foreground/80 mt-1"> Agent </p>
</div>
)}
</TabsContent>
{/* Tab Content: Findings */}
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
{runDetails.findings && runDetails.findings.length > 0 ? (
runDetails.findings.map((finding) => (
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<SeverityBadge severity={finding.severity} />
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
{finding.category}
</Badge>
<span className="font-mono text-xs text-muted-foreground">
{finding.path}:{finding.line}
</span>
</div>
<CardTitle className="text-base font-bold text-foreground tracking-tight">
{finding.title}
</CardTitle>
</div>
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
<Info className="w-3.5 h-3.5" />
: {(finding.confidence * 100).toFixed(0)}%
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<div className="font-semibold text-foreground mb-1"></div>
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
</div>
{finding.evidence && (
<div>
<div className="font-semibold text-foreground mb-1"></div>
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
{finding.evidence}
</pre>
</div>
)}
{finding.suggestion && (
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
<CheckCircle2 className="w-4 h-4" />
</div>
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
</div>
)}
</CardContent>
</Card>
))
) : (
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
</div>
)}
</TabsContent>
{/* Tab Content: Run Log */}
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
{/* Steps */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Layers className="w-4 h-4" />
(Steps)
</h3>
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
<th className="p-3"></th>
<th className="p-3"></th>
<th className="p-3"></th>
<th className="p-3"></th>
<th className="p-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{runDetails.steps && runDetails.steps.length > 0 ? (
runDetails.steps.map((step) => (
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
<td className="p-3">
<StatusBadge status={step.status} />
</td>
<td className="p-3 font-mono text-xs">
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
</td>
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Comments */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4" />
(Comments)
</h3>
<div className="space-y-3">
{runDetails.comments && runDetails.comments.length > 0 ? (
runDetails.comments.map((comment) => (
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
{comment.path && (
<span className="font-mono text-muted-foreground">
{comment.path}:{comment.line}
</span>
)}
{comment.giteaCommentId && (
<Badge variant="outline" className="text-[10px] border-border/60">
Gitea ID: {comment.giteaCommentId}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<StatusBadge status={comment.status} />
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
</div>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
{comment.body}
</p>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
</div>
)}
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
<h3 className="text-lg font-bold text-foreground"></h3>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
)}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import ReviewSessionsPage from '../ReviewSessionsPage';
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
vi.mock('@/services/reviewSessionService', () => ({
fetchReviewRuns: vi.fn(),
fetchReviewRunDetails: vi.fn(),
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe('ReviewSessionsPage', () => {
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
const mockRuns = {
data: [
{
id: 'run-1',
idempotencyKey: 'key-1',
eventType: 'pull_request' as const,
status: 'succeeded' as const,
owner: 'test-owner',
repo: 'test-repo',
cloneUrl: 'http://clone',
prNumber: 42,
attempts: 1,
maxAttempts: 2,
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
},
],
};
const mockDetails = {
run: mockRuns.data[0],
steps: [],
findings: [],
comments: [],
sessionTree: {
id: 'session-main',
agentType: 'review-main-agent',
model: 'gpt-main',
status: 'completed',
metadata: {},
startedAt: '2026-05-25T00:00:00.000Z',
completedAt: '2026-05-25T00:01:00.000Z',
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:01:00.000Z',
messages: [
{
id: 'msg-1',
sessionId: 'session-main',
sequence: 1,
role: 'user',
content: 'Hello',
metadata: {},
createdAt: '2026-05-25T00:00:05.000Z',
},
],
toolCalls: [
{
id: 'tool-1',
sessionId: 'session-main',
sequence: 1,
toolName: 'search_code',
status: 'completed',
arguments: {},
createdAt: '2026-05-25T00:00:10.000Z',
},
],
invocations: [
{
id: 'inv-1',
parentSessionId: 'session-main',
childSessionId: 'session-sub-1',
sequence: 1,
agentType: 'security-reviewer',
model: 'gpt-sub-a',
status: 'completed',
input: {},
createdAt: '2026-05-25T00:00:15.000Z',
childSession: {
id: 'session-sub-1',
parentSessionId: 'session-main',
parentInvocationId: 'inv-1',
agentType: 'security-reviewer',
model: 'gpt-sub-a',
status: 'completed',
metadata: {},
startedAt: '2026-05-25T00:00:15.000Z',
completedAt: '2026-05-25T00:00:30.000Z',
createdAt: '2026-05-25T00:00:15.000Z',
updatedAt: '2026-05-25T00:00:30.000Z',
messages: [],
toolCalls: [],
invocations: [],
},
},
{
id: 'inv-2',
parentSessionId: 'session-main',
childSessionId: 'session-sub-2',
sequence: 2,
agentType: 'quality-reviewer',
model: 'gpt-sub-b',
status: 'completed',
input: {},
createdAt: '2026-05-25T00:00:35.000Z',
childSession: {
id: 'session-sub-2',
parentSessionId: 'session-main',
parentInvocationId: 'inv-2',
agentType: 'quality-reviewer',
model: 'gpt-sub-b',
status: 'completed',
metadata: {},
startedAt: '2026-05-25T00:00:35.000Z',
completedAt: '2026-05-25T00:00:50.000Z',
createdAt: '2026-05-25T00:00:35.000Z',
updatedAt: '2026-05-25T00:00:50.000Z',
messages: [],
toolCalls: [],
invocations: [],
},
},
],
},
};
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
renderWithQuery(<ReviewSessionsPage />);
// Wait for details to load and render
const mainAgentText = await screen.findByText('主代理: review-main-agent');
expect(mainAgentText).toBeInTheDocument();
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
// Assert subagents are rendered
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
// Assert tool calls count is visible in the details panel tabs
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
});
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
const mockRuns = {
data: [
{
id: 'run-2',
idempotencyKey: 'key-2',
eventType: 'pull_request' as const,
status: 'failed' as const,
owner: 'test-owner',
repo: 'test-repo',
cloneUrl: 'http://clone',
prNumber: 43,
attempts: 1,
maxAttempts: 2,
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
},
],
};
const mockDetails = {
run: mockRuns.data[0],
steps: [],
findings: [
{
id: 'finding-1',
runId: 'run-2',
fingerprint: 'fp-1',
category: 'security',
severity: 'high',
confidence: 0.9,
path: 'src/db.ts',
line: 10,
title: 'SQL Injection vulnerability',
detail: 'Direct string concatenation in query',
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
suggestion: 'Use parameterized queries',
published: false,
},
],
comments: [],
sessionTree: {
id: 'session-main-2',
agentType: 'review-main-agent',
model: 'gpt-main',
status: 'failed',
metadata: {},
startedAt: '2026-05-25T00:00:00.000Z',
completedAt: '2026-05-25T00:01:00.000Z',
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:01:00.000Z',
messages: [],
toolCalls: [],
invocations: [
{
id: 'inv-failed',
parentSessionId: 'session-main-2',
sequence: 1,
agentType: 'security-reviewer',
model: 'gpt-sub-a',
status: 'failed',
input: {},
error: 'Failed to initialize subagent',
createdAt: '2026-05-25T00:00:15.000Z',
},
],
},
};
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
renderWithQuery(<ReviewSessionsPage />);
// Wait for details to load and render
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
expect(failedSubagentText).toBeInTheDocument();
const user = userEvent.setup();
// Switch to findings tab
const findingsTab = screen.getByText('审查结果 (1)');
expect(findingsTab).toBeInTheDocument();
await user.click(findingsTab);
// Assert finding title still renders
const findingTitle = await screen.findByText('SQL Injection vulnerability');
expect(findingTitle).toBeInTheDocument();
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
});
it('Scenario 3: asserts no legacy review labels are visible', async () => {
const mockRuns = {
data: [
{
id: 'run-3',
idempotencyKey: 'key-3',
eventType: 'pull_request' as const,
status: 'succeeded' as const,
owner: 'test-owner',
repo: 'test-repo',
cloneUrl: 'http://clone',
prNumber: 44,
attempts: 1,
maxAttempts: 2,
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
},
],
};
const mockDetails = {
run: mockRuns.data[0],
steps: [],
findings: [],
comments: [],
sessionTree: {
id: 'session-main-3',
agentType: 'review-main-agent',
model: 'gpt-main',
status: 'completed',
metadata: {},
startedAt: '2026-05-25T00:00:00.000Z',
completedAt: '2026-05-25T00:01:00.000Z',
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:01:00.000Z',
messages: [],
toolCalls: [],
invocations: [],
},
};
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
renderWithQuery(<ReviewSessionsPage />);
await waitFor(() => {
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
});
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
legacyLabels.forEach((label) => {
expect(screen.queryByText(label)).toBeNull();
});
expect(screen.queryByText('分流')).toBeNull();
expect(screen.queryByText('专家')).toBeNull();
expect(screen.queryByText('裁判')).toBeNull();
expect(screen.queryByText('规划')).toBeNull();
});
});

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;
@@ -32,6 +30,21 @@ export interface ConfigResponse {
groups: ConfigGroupDto[];
}
export type NotificationTestProvider = 'feishu' | 'wecom';
export type NotificationTestStatus = 'success' | 'error';
export interface NotificationTestRecordDto {
id: string;
provider: string;
status: NotificationTestStatus;
message: string;
timestamp: string;
}
export interface NotificationTestHistoryResponse {
data: NotificationTestRecordDto[];
}
export const fetchConfig = async (): Promise<ConfigResponse> => {
const response = await api.get<ConfigResponse>('/config');
return response.data;
@@ -44,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
export const resetConfig = async (keys: string[]): Promise<void> => {
await api.post('/config/reset', { keys });
};
export const testNotification = async (provider: NotificationTestProvider): Promise<void> => {
await api.post('/config/notification/test', { provider });
};
export const fetchNotificationTestHistory = async (): Promise<NotificationTestRecordDto[]> => {
const response = await api.get<NotificationTestHistoryResponse>('/config/notification/test/history');
return response.data.data;
};

View File

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

View File

@@ -4,6 +4,7 @@ export interface Repository {
name: string;
webhook_status: 'active' | 'inactive';
hook_id: number | null;
project_review_prompt: string | null;
}
export interface PaginatedRepositories {
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
});
return data;
};
export const updateRepositoryProjectPrompt = async (
repoName: string,
projectReviewPrompt: string
): Promise<{ success: boolean; project_review_prompt: string | null }> => {
const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
project_review_prompt: projectReviewPrompt,
});
return data;
};

View File

@@ -0,0 +1,147 @@
import api from '@/lib/api';
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
export interface ReviewRun {
id: string;
idempotencyKey: string;
eventType: 'pull_request' | 'commit_status';
status: ReviewRunStatus;
owner: string;
repo: string;
cloneUrl: string;
headCloneUrl?: string;
prNumber?: number;
relatedPrNumber?: number;
baseSha?: string;
headSha?: string;
commitSha?: string;
commitMessage?: string;
attempts: number;
maxAttempts: number;
createdAt: string;
updatedAt: string;
startedAt?: string;
finishedAt?: string;
error?: string;
}
export interface ReviewStep {
id: string;
runId: string;
stepName: string;
agentName?: string;
status: 'started' | 'succeeded' | 'failed';
startedAt: string;
finishedAt?: string;
latencyMs?: number;
inputRef?: string;
outputRef?: string;
error?: string;
}
export interface Finding {
id: string;
runId: string;
fingerprint: string;
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
severity: 'high' | 'medium' | 'low';
confidence: number;
path: string;
line: number;
title: string;
detail: string;
evidence: string;
suggestion: string;
published: boolean;
}
export interface ReviewCommentRecord {
id: string;
runId: string;
path?: string;
line?: number;
body: string;
giteaCommentId?: number;
status: 'pending' | 'published' | 'failed';
createdAt: string;
fingerprint?: string;
}
export interface AgentMessageRecord {
id: string;
sessionId: string;
sequence: number;
role: string;
content: any;
metadata: Record<string, any>;
createdAt: string;
}
export interface AgentToolCallRecord {
id: string;
sessionId: string;
messageId?: string;
sequence: number;
toolName: string;
status: 'running' | 'completed' | 'failed';
arguments: any;
result?: any;
error?: any;
createdAt: string;
completedAt?: string;
}
export interface AgentInvocationRecord {
id: string;
parentSessionId: string;
childSessionId?: string;
sequence: number;
agentType: string;
model: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
input: any;
result?: any;
error?: any;
createdAt: string;
completedAt?: string;
}
export interface AgentSessionTree {
id: string;
parentSessionId?: string;
parentInvocationId?: string;
agentType: string;
model: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
metadata: Record<string, any>;
finalResult?: any;
error?: any;
startedAt: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
messages: AgentMessageRecord[];
toolCalls: AgentToolCallRecord[];
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
}
export interface ReviewRunDetails {
run: ReviewRun;
steps: ReviewStep[];
findings: Finding[];
comments: ReviewCommentRecord[];
sessionTree?: AgentSessionTree | null;
}
export const fetchReviewRuns = async (limit: number = 50): Promise<{ data: ReviewRun[] }> => {
const response = await api.get<{ data: ReviewRun[] }>('/review/runs', {
params: { limit },
});
return response.data;
};
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
return response.data;
};

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

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