mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
Merge pull request #83 from kaitranntt/kai/feat/cliproxy-deep-integration
feat(cliproxy): implement comprehensive UI and backend enhancements
This commit is contained in:
+175
@@ -0,0 +1,175 @@
|
||||
[2025-12-12 02:50:06] [info] [main.go:413] CLIProxyAPI Version: 6.6.5, Commit: fc054db, BuiltAt: 2025-12-12T00:54:34Z
|
||||
[2025-12-12 02:50:06] [info] [server.go:466] management routes registered after secret key configuration
|
||||
[2025-12-12 02:50:06] [info] [watcher.go:1000] full client load complete - 5 clients (5 auth files + 0 Gemini API keys + 0 Vertex API keys + 0 Claude API keys + 0 Codex keys + 0 OpenAI-compat)
|
||||
[2025-12-12 02:50:06] [info] [service.go:543] file watcher started for config and auth directory changes
|
||||
[2025-12-12 02:50:06] [info] [service.go:549] core auth auto-refresh started (interval=15m0s)
|
||||
[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/"
|
||||
[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/v1/models"
|
||||
[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/"
|
||||
[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/"
|
||||
[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 49ms | ::1 | GET "/v0/management/debug"
|
||||
[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 50ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/usage"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 0s | ::1 | GET "/v1/models"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/auth-files"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models"
|
||||
[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/"
|
||||
[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/v1/models"
|
||||
[2025-12-12 02:50:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:42 | 200 | 2.115s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 773ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 1.568s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:48 | 200 | 6.774s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:49] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:49 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 7.951s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/"
|
||||
[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/"
|
||||
[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 50ms | ::1 | GET "/v0/management/debug"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/usage"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | ::1 | GET "/v1/models"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/auth-files"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/config.yaml"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models"
|
||||
[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:52 | 200 | 2.247s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 13.83s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 3.023s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 10.921s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.254s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true"
|
||||
[2025-12-12 02:51:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:03 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:05] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:05 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 3.412s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 8.706s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:07] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:07 | 200 | 6.799s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:08 | 200 | 10.614s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:09 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:10] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:10 | 200 | 1.661s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:11 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:13 | 200 | 3.155s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:16 | 200 | 8.675s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.132s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.018s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.137s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.219s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:19 | 200 | 2.993s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 955ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 1.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 23.465s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 2.506s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:22 | 200 | 3.102s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/usage"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | ::1 | GET "/v1/models"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config.yaml"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/auth-files"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models"
|
||||
[2025-12-12 02:51:29] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:29 | 200 | 6.533s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:31] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:31 | 200 | 1.337s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:34 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:35] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:35 | 200 | 5.291s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:36] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:36 | 200 | 1.482s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:38] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:38 | 200 | 2.867s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 5.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:43 | 200 | 7.802s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:47 | 200 | 4.344s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 4.022s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:51:54] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:54 | 200 | 5.541s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:57 | 200 | 3.579s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:58 | 200 | 4.434s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:51:59] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:59 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:01] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:01 | 200 | 6.851s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:03 | 200 | 2.477s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:04] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:04 | 200 | 2.796s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:06 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:08 | 200 | 3.953s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:09 | 200 | 741ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:11 | 200 | 1.983s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 3.488s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:52:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/usage"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 0s | ::1 | GET "/v1/models"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/auth-files"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models"
|
||||
[2025-12-12 02:52:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:42 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:54:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/usage"
|
||||
[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 0s | ::1 | GET "/v1/models"
|
||||
[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml"
|
||||
[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/auth-files"
|
||||
[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config"
|
||||
[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models"
|
||||
[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:57:15] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:15 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:57:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:16 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch"
|
||||
[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 5.549s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true"
|
||||
[2025-12-12 02:57:26] [error] [service.go:613] error stopping API server: failed to shutdown HTTP server: context deadline exceeded
|
||||
[2025-12-12 02:57:26] [error] [service.go:425] service shutdown returned error: failed to shutdown HTTP server: context deadline exceeded
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verify UI bundle size is under 1MB gzipped
|
||||
* React + shadcn/ui dashboard typically ranges 600-900KB
|
||||
* Verify UI bundle size stays reasonable
|
||||
*
|
||||
* Current stack: React + TanStack Query + Radix UI + shadcn/ui + Recharts
|
||||
* Expected range: 800KB - 1.5MB gzipped for full-featured dashboard
|
||||
*
|
||||
* This is a developer tool, not a public-facing site, so we optimize for
|
||||
* features over minimal bundle size. The limit is a sanity check to catch
|
||||
* accidental large dependencies, not a hard performance target.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -10,7 +16,7 @@ const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const UI_DIR = path.join(__dirname, '../dist/ui');
|
||||
const MAX_SIZE = 1024 * 1024; // 1MB
|
||||
const MAX_SIZE = 1.5 * 1024 * 1024; // 1.5MB - reasonable for full-featured React dashboard
|
||||
|
||||
function getGzipSize(filePath) {
|
||||
const content = fs.readFileSync(filePath);
|
||||
@@ -40,9 +46,10 @@ if (!fs.existsSync(UI_DIR)) {
|
||||
|
||||
const totalSize = walkDir(UI_DIR);
|
||||
const sizeKB = (totalSize / 1024).toFixed(1);
|
||||
const maxKB = (MAX_SIZE / 1024).toFixed(0);
|
||||
|
||||
if (totalSize > MAX_SIZE) {
|
||||
console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: 1024KB)`);
|
||||
console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: ${maxKB}KB)`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[OK] Bundle size: ${sizeKB}KB gzipped`);
|
||||
|
||||
@@ -130,11 +130,54 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync registry with actual token files
|
||||
* Removes stale entries where token file no longer exists
|
||||
* Called automatically when loading accounts
|
||||
*/
|
||||
function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
|
||||
const authDir = getAuthDir();
|
||||
let modified = false;
|
||||
|
||||
for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) {
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
const staleIds: string[] = [];
|
||||
|
||||
for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) {
|
||||
const tokenPath = path.join(authDir, meta.tokenFile);
|
||||
if (!fs.existsSync(tokenPath)) {
|
||||
staleIds.push(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale accounts
|
||||
for (const id of staleIds) {
|
||||
delete providerAccounts.accounts[id];
|
||||
modified = true;
|
||||
|
||||
// Update default if deleted
|
||||
if (providerAccounts.default === id) {
|
||||
const remainingIds = Object.keys(providerAccounts.accounts);
|
||||
providerAccounts.default = remainingIds[0] || 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts for a provider
|
||||
*/
|
||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Sync with actual token files (removes stale entries)
|
||||
if (syncRegistryWithTokenFiles(registry)) {
|
||||
saveAccountsRegistry(registry);
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts) {
|
||||
|
||||
@@ -23,7 +23,17 @@ interface ProviderSettings {
|
||||
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** Internal API key for CCS-managed requests */
|
||||
const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
|
||||
/** Simple secret key for Control Panel login (user-facing) */
|
||||
export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
|
||||
/**
|
||||
* Config version - bump when config format changes to trigger regeneration
|
||||
* v1: Initial config (port, auth-dir, api-keys only)
|
||||
* v2: Full-featured config with dashboard, quota mgmt, simplified key
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 2;
|
||||
|
||||
/** Provider display names (static metadata) */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
|
||||
@@ -112,26 +122,68 @@ export function getBinDir(): string {
|
||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||
|
||||
// Unified config with all providers
|
||||
const config = `# CLIProxyAPI unified config generated by CCS
|
||||
# Supports: gemini, codex, agy, qwen (concurrent usage)
|
||||
// Unified config with enhanced CLIProxyAPI features
|
||||
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||
# Generated: ${new Date().toISOString()}
|
||||
#
|
||||
# This config is auto-managed by CCS. Manual edits may be overwritten.
|
||||
# Use 'ccs doctor' to regenerate with latest settings.
|
||||
|
||||
# =============================================================================
|
||||
# Server Settings
|
||||
# =============================================================================
|
||||
|
||||
port: ${port}
|
||||
debug: false
|
||||
logging-to-file: false
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# CCS internal authentication
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
|
||||
# Write logs to file (stored in ~/.ccs/cliproxy/logs/)
|
||||
logging-to-file: true
|
||||
|
||||
# Log individual API requests for debugging/analytics
|
||||
request-log: true
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard & Management
|
||||
# =============================================================================
|
||||
|
||||
# Enable usage statistics for CCS dashboard analytics
|
||||
usage-statistics-enabled: true
|
||||
|
||||
# Remote management API for CCS dashboard integration
|
||||
remote-management:
|
||||
allow-remote: true
|
||||
secret-key: "${CCS_CONTROL_PANEL_SECRET}"
|
||||
disable-control-panel: false
|
||||
|
||||
# =============================================================================
|
||||
# Reliability & Quota Management
|
||||
# =============================================================================
|
||||
|
||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||
request-retry: 0
|
||||
max-retry-interval: 0
|
||||
|
||||
# Auto-switch accounts on quota exceeded (429)
|
||||
# This enables seamless multi-account rotation when rate limited
|
||||
quota-exceeded:
|
||||
switch-project: true
|
||||
switch-preview-model: true
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
# =============================================================================
|
||||
|
||||
# API keys for CCS internal requests
|
||||
api-keys:
|
||||
- "${CCS_INTERNAL_API_KEY}"
|
||||
|
||||
# OAuth tokens stored in auth/ directory
|
||||
# CLIProxyAPI auto-discovers auth files in subdirectories
|
||||
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
||||
|
||||
# All providers configured - routes by model name
|
||||
# No provider-specific sections needed - OAuth auth files provide credentials
|
||||
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||
auth-dir: "${authDir.replace(/\\\\/g, '/')}"
|
||||
`;
|
||||
|
||||
return config;
|
||||
@@ -162,6 +214,67 @@ export function generateConfig(
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force regenerate config.yaml with latest settings
|
||||
* Deletes existing config and creates fresh one with current port
|
||||
* @returns Path to new config file
|
||||
*/
|
||||
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Read existing port if config exists (preserve user's port choice)
|
||||
let effectivePort = port;
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(/^port:\s*(\d+)/m);
|
||||
if (portMatch) {
|
||||
effectivePort = parseInt(portMatch[1], 10);
|
||||
}
|
||||
} catch {
|
||||
// Use default port if reading fails
|
||||
}
|
||||
// Delete existing config
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 });
|
||||
|
||||
// Generate fresh config
|
||||
const configContent = generateUnifiedConfigContent(effectivePort);
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config needs regeneration (version mismatch)
|
||||
* @returns true if config should be regenerated
|
||||
*/
|
||||
export function configNeedsRegeneration(): boolean {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false; // Will be created on first use
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Check for version marker
|
||||
const versionMatch = content.match(/CCS v(\d+)/);
|
||||
if (!versionMatch) {
|
||||
return true; // No version marker = old config
|
||||
}
|
||||
|
||||
const configVersion = parseInt(versionMatch[1], 10);
|
||||
return configVersion < CLIPROXY_CONFIG_VERSION;
|
||||
} catch {
|
||||
return true; // Error reading = regenerate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config exists for provider
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
// Config generation
|
||||
export {
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
getClaudeEnvVars,
|
||||
getEffectiveEnvVars,
|
||||
getProviderSettingsPath,
|
||||
@@ -62,6 +64,7 @@ export {
|
||||
configExists,
|
||||
deleteConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
CLIPROXY_CONFIG_VERSION,
|
||||
} from './config-generator';
|
||||
|
||||
// Base config loader (for reading config/base-*.settings.json)
|
||||
@@ -98,3 +101,23 @@ export {
|
||||
getProviderTokenDir,
|
||||
displayAuthStatus,
|
||||
} from './auth-handler';
|
||||
|
||||
// Stats fetcher
|
||||
export type { CliproxyStats } from './stats-fetcher';
|
||||
export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher';
|
||||
|
||||
// OpenAI compatibility layer
|
||||
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
||||
export {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
addOpenAICompatProvider,
|
||||
updateOpenAICompatProvider,
|
||||
removeOpenAICompatProvider,
|
||||
OPENROUTER_TEMPLATE,
|
||||
TOGETHER_TEMPLATE,
|
||||
} from './openai-compat-manager';
|
||||
|
||||
// Service manager (background CLIProxy for dashboard)
|
||||
export type { ServiceStartResult } from './service-manager';
|
||||
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* OpenAI Compatibility Layer Manager
|
||||
*
|
||||
* Manages OpenAI-compatible providers (OpenRouter, Together, etc.)
|
||||
* in CLIProxyAPI's config.yaml.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getConfigPath } from './config-generator';
|
||||
|
||||
/** Model alias configuration */
|
||||
export interface OpenAICompatModel {
|
||||
name: string; // Upstream model name
|
||||
alias: string; // Client-visible alias
|
||||
}
|
||||
|
||||
/** OpenAI-compatible provider configuration */
|
||||
export interface OpenAICompatProvider {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: OpenAICompatModel[];
|
||||
}
|
||||
|
||||
/** Config.yaml structure for parsing */
|
||||
interface ConfigYaml {
|
||||
port?: number;
|
||||
'api-keys'?: string[];
|
||||
'auth-dir'?: string;
|
||||
'openai-compatibility'?: Array<{
|
||||
name: string;
|
||||
'base-url': string;
|
||||
headers?: Record<string, string>;
|
||||
'api-key-entries': Array<{
|
||||
'api-key': string;
|
||||
'proxy-url'?: string;
|
||||
}>;
|
||||
models?: Array<{
|
||||
name: string;
|
||||
alias: string;
|
||||
}>;
|
||||
}>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current config.yaml
|
||||
*/
|
||||
function loadConfig(): ConfigYaml {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
return (yaml.load(content) as ConfigYaml) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config.yaml with proper formatting
|
||||
*/
|
||||
function saveConfig(config: ConfigYaml): void {
|
||||
const configPath = getConfigPath();
|
||||
const content = yaml.dump(config, {
|
||||
lineWidth: -1, // Disable line wrapping
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
|
||||
fs.writeFileSync(configPath, content, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured OpenAI-compatible providers
|
||||
*/
|
||||
export function listOpenAICompatProviders(): OpenAICompatProvider[] {
|
||||
const config = loadConfig();
|
||||
const providers = config['openai-compatibility'] || [];
|
||||
|
||||
return providers.map((p) => ({
|
||||
name: p.name,
|
||||
baseUrl: p['base-url'],
|
||||
apiKey: p['api-key-entries']?.[0]?.['api-key'] || '',
|
||||
models: (p.models || []).map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific provider by name
|
||||
*/
|
||||
export function getOpenAICompatProvider(name: string): OpenAICompatProvider | null {
|
||||
const providers = listOpenAICompatProviders();
|
||||
return providers.find((p) => p.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new OpenAI-compatible provider
|
||||
* @throws Error if provider with same name already exists
|
||||
*/
|
||||
export function addOpenAICompatProvider(provider: OpenAICompatProvider): void {
|
||||
const config = loadConfig();
|
||||
|
||||
// Initialize array if not exists
|
||||
if (!config['openai-compatibility']) {
|
||||
config['openai-compatibility'] = [];
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = config['openai-compatibility'].find((p) => p.name === provider.name);
|
||||
if (existing) {
|
||||
throw new Error(`Provider '${provider.name}' already exists`);
|
||||
}
|
||||
|
||||
// Add new provider
|
||||
config['openai-compatibility'].push({
|
||||
name: provider.name,
|
||||
'base-url': provider.baseUrl,
|
||||
'api-key-entries': [{ 'api-key': provider.apiKey }],
|
||||
models: provider.models.map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
})),
|
||||
});
|
||||
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing provider
|
||||
* @throws Error if provider doesn't exist
|
||||
*/
|
||||
export function updateOpenAICompatProvider(
|
||||
name: string,
|
||||
updates: Partial<OpenAICompatProvider>
|
||||
): void {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config['openai-compatibility']) {
|
||||
throw new Error(`Provider '${name}' not found`);
|
||||
}
|
||||
|
||||
const index = config['openai-compatibility'].findIndex((p) => p.name === name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Provider '${name}' not found`);
|
||||
}
|
||||
|
||||
const provider = config['openai-compatibility'][index];
|
||||
|
||||
// Apply updates
|
||||
if (updates.baseUrl) {
|
||||
provider['base-url'] = updates.baseUrl;
|
||||
}
|
||||
if (updates.apiKey) {
|
||||
provider['api-key-entries'] = [{ 'api-key': updates.apiKey }];
|
||||
}
|
||||
if (updates.models) {
|
||||
provider.models = updates.models.map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.alias,
|
||||
}));
|
||||
}
|
||||
if (updates.name && updates.name !== name) {
|
||||
provider.name = updates.name;
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export function removeOpenAICompatProvider(name: string): boolean {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config['openai-compatibility']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = config['openai-compatibility'].findIndex((p) => p.name === name);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config['openai-compatibility'].splice(index, 1);
|
||||
|
||||
// Remove empty array
|
||||
if (config['openai-compatibility'].length === 0) {
|
||||
delete config['openai-compatibility'];
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pre-configured OpenRouter template */
|
||||
export const OPENROUTER_TEMPLATE: Omit<OpenAICompatProvider, 'apiKey'> = {
|
||||
name: 'openrouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
models: [
|
||||
{ name: 'anthropic/claude-3.5-sonnet', alias: 'claude-sonnet' },
|
||||
{ name: 'anthropic/claude-3-opus', alias: 'claude-opus' },
|
||||
{ name: 'google/gemini-pro-1.5', alias: 'gemini-pro' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Pre-configured Together template */
|
||||
export const TOGETHER_TEMPLATE: Omit<OpenAICompatProvider, 'apiKey'> = {
|
||||
name: 'together',
|
||||
baseUrl: 'https://api.together.xyz/v1',
|
||||
models: [
|
||||
{ name: 'meta-llama/Llama-3-70b-chat-hf', alias: 'llama-70b' },
|
||||
{ name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', alias: 'mixtral' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* CLIProxy Service Manager
|
||||
*
|
||||
* Manages CLIProxyAPI as a background service for the CCS dashboard.
|
||||
* Ensures the proxy is running when needed for:
|
||||
* - Control Panel integration (management.html)
|
||||
* - Stats fetching
|
||||
* - OAuth flows
|
||||
*
|
||||
* Unlike cliproxy-executor.ts which runs proxy per-session,
|
||||
* this module manages a persistent background instance.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as net from 'net';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './config-generator';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
|
||||
/** Background proxy process reference */
|
||||
let proxyProcess: ChildProcess | null = null;
|
||||
|
||||
/** Cleanup registered flag */
|
||||
let cleanupRegistered = false;
|
||||
|
||||
/**
|
||||
* Wait for TCP port to become available
|
||||
*/
|
||||
async function waitForPort(
|
||||
port: number,
|
||||
timeout: number = 5000,
|
||||
pollInterval: number = 100
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.setTimeout(500, () => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
});
|
||||
});
|
||||
|
||||
return true; // Connection successful
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cleanup handlers to stop proxy on process exit
|
||||
*/
|
||||
function registerCleanup(): void {
|
||||
if (cleanupRegistered) return;
|
||||
|
||||
const cleanup = () => {
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
}
|
||||
};
|
||||
|
||||
process.once('exit', cleanup);
|
||||
process.once('SIGTERM', cleanup);
|
||||
process.once('SIGINT', cleanup);
|
||||
|
||||
cleanupRegistered = true;
|
||||
}
|
||||
|
||||
export interface ServiceStartResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
port: number;
|
||||
configRegenerated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CLIProxy service is running
|
||||
*
|
||||
* If proxy is already running, returns immediately.
|
||||
* If not, spawns a new background instance.
|
||||
*
|
||||
* @param port CLIProxy port (default: 8317)
|
||||
* @param verbose Show debug output
|
||||
* @returns Result indicating success and whether it was already running
|
||||
*/
|
||||
export async function ensureCliproxyService(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
verbose: boolean = false
|
||||
): Promise<ServiceStartResult> {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy-service] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already running (from another process or previous start)
|
||||
log(`Checking if CLIProxy is running on port ${port}...`);
|
||||
const running = await isCliproxyRunning(port);
|
||||
|
||||
// Check if config needs update (even if running)
|
||||
let configRegenerated = false;
|
||||
if (configNeedsRegeneration()) {
|
||||
log('Config outdated, regenerating...');
|
||||
regenerateConfig(port);
|
||||
configRegenerated = true;
|
||||
}
|
||||
|
||||
if (running) {
|
||||
log('CLIProxy already running');
|
||||
if (configRegenerated) {
|
||||
log('Config was updated - running instance will use new config on next restart');
|
||||
}
|
||||
return { started: true, alreadyRunning: true, port, configRegenerated };
|
||||
}
|
||||
|
||||
// Need to start new instance
|
||||
log('CLIProxy not running, starting background instance...');
|
||||
|
||||
// 1. Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
log(`Binary ready: ${binaryPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port,
|
||||
error: `Failed to prepare binary: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Ensure/regenerate config if needed
|
||||
let configPath: string;
|
||||
if (configNeedsRegeneration()) {
|
||||
log('Config needs regeneration, updating...');
|
||||
configPath = regenerateConfig(port);
|
||||
} else {
|
||||
// generateConfig only creates if doesn't exist
|
||||
configPath = generateConfig('gemini', port); // Provider doesn't matter for unified config
|
||||
}
|
||||
log(`Config ready: ${configPath}`);
|
||||
|
||||
// 3. Spawn background process
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxyProcess = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: true, // Allow process to run independently
|
||||
});
|
||||
|
||||
// Forward output in verbose mode
|
||||
if (verbose) {
|
||||
proxyProcess.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy] ${data.toString()}`);
|
||||
});
|
||||
proxyProcess.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Don't let this process prevent parent from exiting
|
||||
proxyProcess.unref();
|
||||
|
||||
// Handle spawn errors
|
||||
proxyProcess.on('error', (error) => {
|
||||
log(`Spawn error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Register cleanup handlers
|
||||
registerCleanup();
|
||||
|
||||
// 4. Wait for proxy to be ready
|
||||
log(`Waiting for CLIProxy on port ${port}...`);
|
||||
const ready = await waitForPort(port, 5000);
|
||||
|
||||
if (!ready) {
|
||||
// Kill failed process
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
}
|
||||
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port,
|
||||
error: `CLIProxy failed to start within 5s on port ${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
log(`CLIProxy service started on port ${port}`);
|
||||
return { started: true, alreadyRunning: false, port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the managed CLIProxy service
|
||||
*/
|
||||
export function stopCliproxyService(): boolean {
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status
|
||||
*/
|
||||
export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
running: boolean;
|
||||
managedByUs: boolean;
|
||||
port: number;
|
||||
}> {
|
||||
const running = await isCliproxyRunning(port);
|
||||
const managedByUs = proxyProcess !== null && !proxyProcess.killed;
|
||||
|
||||
return { running, managedByUs, port };
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* CLIProxyAPI Stats Fetcher
|
||||
*
|
||||
* Fetches usage statistics from CLIProxyAPI's management API.
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** Usage statistics from CLIProxyAPI */
|
||||
export interface CliproxyStats {
|
||||
/** Total number of requests processed */
|
||||
totalRequests: number;
|
||||
/** Token counts */
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
};
|
||||
/** Requests grouped by model */
|
||||
requestsByModel: Record<string, number>;
|
||||
/** Requests grouped by provider */
|
||||
requestsByProvider: Record<string, number>;
|
||||
/** Number of quota exceeded (429) events */
|
||||
quotaExceededCount: number;
|
||||
/** Number of request retries */
|
||||
retryCount: number;
|
||||
/** Timestamp of stats collection */
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
|
||||
interface UsageApiResponse {
|
||||
failed_requests?: number;
|
||||
usage?: {
|
||||
total_requests?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
total_tokens?: number;
|
||||
apis?: Record<
|
||||
string,
|
||||
{
|
||||
total_requests?: number;
|
||||
total_tokens?: number;
|
||||
models?: Record<
|
||||
string,
|
||||
{
|
||||
total_requests?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage statistics from CLIProxyAPI management API
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Stats object or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyStats(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyStats | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UsageApiResponse;
|
||||
const usage = data.usage;
|
||||
|
||||
// Extract models and providers from the nested API structure
|
||||
const requestsByModel: Record<string, number> = {};
|
||||
const requestsByProvider: Record<string, number> = {};
|
||||
|
||||
if (usage?.apis) {
|
||||
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
||||
requestsByProvider[provider] = providerData.total_requests ?? 0;
|
||||
if (providerData.models) {
|
||||
for (const [model, modelData] of Object.entries(providerData.models)) {
|
||||
requestsByModel[model] = modelData.total_requests ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the response to our interface
|
||||
return {
|
||||
totalRequests: usage?.total_requests ?? 0,
|
||||
tokens: {
|
||||
input: 0, // API doesn't provide input/output breakdown
|
||||
output: 0,
|
||||
total: usage?.total_tokens ?? 0,
|
||||
},
|
||||
requestsByModel,
|
||||
requestsByProvider,
|
||||
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
|
||||
retryCount: 0, // API doesn't track retries separately
|
||||
collectedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
// CLIProxyAPI not running or stats endpoint not available
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Response from /v1/models endpoint */
|
||||
interface ModelsApiResponse {
|
||||
data: CliproxyModel[];
|
||||
object: string;
|
||||
}
|
||||
|
||||
/** Categorized models response for UI */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from CLIProxyAPI /v1/models endpoint
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Categorized models or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyModels(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyModelsResponse | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Use the internal API key for /v1 endpoints
|
||||
Authorization: 'Bearer ccs-internal-managed',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ModelsApiResponse;
|
||||
|
||||
// Group models by owned_by field
|
||||
const byCategory: Record<string, CliproxyModel[]> = {};
|
||||
for (const model of data.data) {
|
||||
const category = model.owned_by || 'other';
|
||||
if (!byCategory[category]) {
|
||||
byCategory[category] = [];
|
||||
}
|
||||
byCategory[category].push(model);
|
||||
}
|
||||
|
||||
// Sort models within each category alphabetically
|
||||
for (const category of Object.keys(byCategory)) {
|
||||
byCategory[category].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
return {
|
||||
models: data.data,
|
||||
byCategory,
|
||||
totalCount: data.data.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxyAPI is running and responsive
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns true if proxy is running
|
||||
*/
|
||||
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||
|
||||
// Use root endpoint - CLIProxyAPI returns server info at /
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* Config Command Handler
|
||||
*
|
||||
* Launches web-based configuration dashboard.
|
||||
* Ensures CLIProxy service is running for dashboard features.
|
||||
* Usage: ccs config [--port PORT] [--dev]
|
||||
*/
|
||||
|
||||
@@ -9,7 +10,9 @@ import getPort from 'get-port';
|
||||
import open from 'open';
|
||||
import { startServer } from '../web-server';
|
||||
import { setupGracefulShutdown } from '../web-server/shutdown';
|
||||
import { initUI, header, ok, info } from '../utils/ui';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { initUI, header, ok, info, warn } from '../utils/ui';
|
||||
|
||||
interface ConfigOptions {
|
||||
port?: number;
|
||||
@@ -72,10 +75,31 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
const verbose = options.dev || false;
|
||||
|
||||
console.log(header('CCS Config Dashboard'));
|
||||
console.log('');
|
||||
console.log(info('Starting server...'));
|
||||
|
||||
// Ensure CLIProxy service is running for dashboard features
|
||||
console.log(info('Starting CLIProxy service...'));
|
||||
const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||
|
||||
if (cliproxyResult.started) {
|
||||
if (cliproxyResult.alreadyRunning) {
|
||||
console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`));
|
||||
if (cliproxyResult.configRegenerated) {
|
||||
console.log(warn('Config updated - restart CLIProxy to apply changes'));
|
||||
}
|
||||
} else {
|
||||
console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`));
|
||||
}
|
||||
} else {
|
||||
console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`));
|
||||
console.log(info('Dashboard will work but Control Panel/Stats may be limited'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log(info('Starting dashboard server...'));
|
||||
|
||||
// Find available port
|
||||
const port =
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
getConfigPath,
|
||||
getInstalledCliproxyVersion,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
configNeedsRegeneration,
|
||||
regenerateConfig,
|
||||
CLIPROXY_CONFIG_VERSION,
|
||||
} from '../cliproxy';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { getEnvironmentDiagnostics } from './environment-diagnostics';
|
||||
@@ -919,17 +922,38 @@ class Doctor {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Config file exists?
|
||||
// 2. Config file exists and is up-to-date?
|
||||
const configSpinner = ora('Checking CLIProxy config').start();
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
configSpinner.succeed();
|
||||
console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'cliproxy/config.yaml',
|
||||
});
|
||||
// Check if config needs regeneration (version mismatch or missing features)
|
||||
if (configNeedsRegeneration()) {
|
||||
configSpinner.warn();
|
||||
console.log(
|
||||
` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...`
|
||||
);
|
||||
|
||||
// Regenerate config with new features
|
||||
regenerateConfig();
|
||||
|
||||
console.log(
|
||||
` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`,
|
||||
});
|
||||
} else {
|
||||
configSpinner.succeed();
|
||||
console.log(
|
||||
` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
configSpinner.info();
|
||||
console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
|
||||
|
||||
@@ -52,12 +52,26 @@ export interface Config {
|
||||
export type EnvValue = string;
|
||||
export type EnvVars = Record<string, EnvValue>;
|
||||
|
||||
/**
|
||||
* Model preset configuration
|
||||
* Stores named model mappings for quick switching
|
||||
*/
|
||||
export interface ModelPreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude CLI settings.json structure
|
||||
* Located at: ~/.claude/settings.json or profile-specific
|
||||
*/
|
||||
export interface Settings {
|
||||
env?: EnvVars;
|
||||
/** Saved model presets for this provider */
|
||||
presets?: ModelPreset[];
|
||||
[key: string]: unknown; // Allow other settings
|
||||
}
|
||||
|
||||
|
||||
+387
-13
@@ -11,7 +11,26 @@ import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/con
|
||||
import { Config, Settings } from '../types/config';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import {
|
||||
getAllAuthStatus,
|
||||
getOAuthConfig,
|
||||
initializeAccounts,
|
||||
triggerOAuth,
|
||||
} from '../cliproxy/auth-handler';
|
||||
import {
|
||||
fetchCliproxyStats,
|
||||
fetchCliproxyModels,
|
||||
isCliproxyRunning,
|
||||
} from '../cliproxy/stats-fetcher';
|
||||
import {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
addOpenAICompatProvider,
|
||||
updateOpenAICompatProvider,
|
||||
removeOpenAICompatProvider,
|
||||
OPENROUTER_TEMPLATE,
|
||||
TOGETHER_TEMPLATE,
|
||||
} from '../cliproxy/openai-compat-manager';
|
||||
import {
|
||||
getAllAccountsSummary,
|
||||
getProviderAccounts,
|
||||
@@ -19,6 +38,7 @@ import {
|
||||
removeAccount as removeAccountFn,
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../cliproxy/config-generator';
|
||||
// Unified config imports
|
||||
import {
|
||||
hasUnifiedConfig,
|
||||
@@ -186,12 +206,25 @@ function updateSettingsFile(
|
||||
|
||||
/**
|
||||
* Helper: Create cliproxy variant settings
|
||||
* Includes base URL and auth token for proper Claude CLI integration
|
||||
*/
|
||||
function createCliproxySettings(name: string, model?: string): string {
|
||||
function createCliproxySettings(name: string, provider: CLIProxyProvider, model?: string): string {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
|
||||
// Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN)
|
||||
const baseEnv = getClaudeEnvVars(provider);
|
||||
|
||||
const settings: Settings = {
|
||||
env: model ? { ANTHROPIC_MODEL: model } : {},
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
|
||||
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
|
||||
ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL:
|
||||
model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
(baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '',
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
@@ -342,7 +375,7 @@ apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
// Create settings file for variant
|
||||
const settingsPath = createCliproxySettings(name, model);
|
||||
const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model);
|
||||
|
||||
// Include account if specified (defaults to 'default' if not provided)
|
||||
config.cliproxy[name] = {
|
||||
@@ -520,10 +553,34 @@ apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Respo
|
||||
/**
|
||||
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
|
||||
*/
|
||||
apiRoutes.delete(
|
||||
'/api/cliproxy/accounts/:provider/:accountId',
|
||||
(req: Request, res: Response): void => {
|
||||
const { provider, accountId } = req.params;
|
||||
apiRoutes.delete('/cliproxy/accounts/:provider/:accountId', (req: Request, res: Response): void => {
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, accountId, deleted: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider
|
||||
* Opens browser for authentication and returns account info when complete
|
||||
*/
|
||||
apiRoutes.post(
|
||||
'/cliproxy/auth/:provider/start',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const { nickname } = req.body;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
@@ -532,12 +589,30 @@ apiRoutes.delete(
|
||||
return;
|
||||
}
|
||||
|
||||
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
try {
|
||||
// Trigger OAuth flow - this opens browser and waits for completion
|
||||
const account = await triggerOAuth(provider as CLIProxyProvider, {
|
||||
add: true, // Always add mode from UI
|
||||
headless: false, // Force interactive mode
|
||||
nickname: nickname || undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, accountId, deleted: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
if (account) {
|
||||
res.json({
|
||||
success: true,
|
||||
account: {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
nickname: account.nickname,
|
||||
provider: account.provider,
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ error: 'Authentication failed or was cancelled' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -658,6 +733,93 @@ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Presets ====================
|
||||
|
||||
/**
|
||||
* GET /api/settings/:profile/presets - Get saved presets for a provider
|
||||
*/
|
||||
apiRoutes.get('/settings/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.json({ presets: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
res.json({ presets: settings.presets || [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/:profile/presets - Create a new preset
|
||||
*/
|
||||
apiRoutes.post('/settings/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
|
||||
|
||||
if (!name || !defaultModel) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, default' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
settings.presets = settings.presets || [];
|
||||
|
||||
// Check for duplicate name
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: defaultModel,
|
||||
opus: opus || defaultModel,
|
||||
sonnet: sonnet || defaultModel,
|
||||
haiku: haiku || defaultModel,
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.status(201).json({ preset });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/:profile/presets/:name - Delete a preset
|
||||
*/
|
||||
apiRoutes.delete('/settings/:profile/presets/:name', (req: Request, res: Response): void => {
|
||||
const { profile, name } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ==================== Accounts ====================
|
||||
|
||||
/**
|
||||
@@ -1023,3 +1185,215 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
||||
* Returns: CliproxyStats or error if proxy not running
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if proxy is running first
|
||||
const running = await isCliproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({
|
||||
error: 'CLIProxyAPI not running',
|
||||
message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch stats from management API
|
||||
const stats = await fetchCliproxyStats();
|
||||
if (!stats) {
|
||||
res.status(503).json({
|
||||
error: 'Stats unavailable',
|
||||
message: 'CLIProxyAPI is running but stats endpoint not responding',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/status - Check CLIProxyAPI running status
|
||||
* Returns: { running: boolean }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const running = await isCliproxyRunning();
|
||||
res.json({ running });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
|
||||
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if proxy is running first
|
||||
const running = await isCliproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({
|
||||
error: 'CLIProxyAPI not running',
|
||||
message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch models from /v1/models endpoint
|
||||
const modelsResponse = await fetchCliproxyModels();
|
||||
if (!modelsResponse) {
|
||||
res.status(503).json({
|
||||
error: 'Models unavailable',
|
||||
message: 'CLIProxyAPI is running but /v1/models endpoint not responding',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(modelsResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// OpenAI Compatibility Layer Routes
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const providers = listOpenAICompatProviders();
|
||||
// Mask API keys for security
|
||||
const masked = providers.map((p) => ({
|
||||
...p,
|
||||
apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '',
|
||||
}));
|
||||
res.json({ providers: masked });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
templates: [
|
||||
{ ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' },
|
||||
{ ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/openai-compat/:name - Get a specific provider
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const provider = getOpenAICompatProvider(req.params.name);
|
||||
if (!provider) {
|
||||
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
|
||||
return;
|
||||
}
|
||||
// Mask API key
|
||||
res.json({
|
||||
...provider,
|
||||
apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/openai-compat - Add a new provider
|
||||
* Body: { name, baseUrl, apiKey, models: [{ name, alias }] }
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name, baseUrl, apiKey, models } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || typeof baseUrl !== 'string') {
|
||||
res.status(400).json({ error: 'baseUrl is required' });
|
||||
return;
|
||||
}
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
res.status(400).json({ error: 'apiKey is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
addOpenAICompatProvider({
|
||||
name,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models: models || [],
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, name });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.includes('already exists')) {
|
||||
res.status(409).json({ error: message });
|
||||
} else {
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/openai-compat/:name - Update a provider
|
||||
* Body: { baseUrl?, apiKey?, models?, name? (for rename) }
|
||||
*/
|
||||
apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { baseUrl, apiKey, models, name: newName } = req.body;
|
||||
|
||||
updateOpenAICompatProvider(req.params.name, {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models,
|
||||
name: newName,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.includes('not found')) {
|
||||
res.status(404).json({ error: message });
|
||||
} else {
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/cliproxy/openai-compat/:name - Remove a provider
|
||||
*/
|
||||
apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const removed = removeOpenAICompatProvider(req.params.name);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: `Provider '${req.params.name}' not found` });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,11 +29,14 @@
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -708,6 +711,8 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="],
|
||||
|
||||
"react-router": ["react-router@7.10.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
|
||||
@@ -720,6 +725,8 @@
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
@@ -788,6 +795,8 @@
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||
|
||||
@@ -40,11 +40,14 @@
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"recharts": "^2.12.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -20,6 +20,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa
|
||||
const CliproxyPage = lazy(() =>
|
||||
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
||||
);
|
||||
const CliproxyControlPanelPage = lazy(() =>
|
||||
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
||||
);
|
||||
const AccountsPage = lazy(() =>
|
||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||
);
|
||||
@@ -71,6 +74,7 @@ export default function App() {
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/api" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Add Account Dialog Component
|
||||
* Simple dialog to add another OAuth account to a provider
|
||||
*
|
||||
* Shows auth command + refresh button (no variant creation)
|
||||
* Triggers OAuth flow server-side to add another account to a provider
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
@@ -14,9 +12,10 @@ import {
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Copy, Check, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, ExternalLink, User } from 'lucide-react';
|
||||
import { useStartAuth } from '@/hooks/use-cliproxy';
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,69 +25,82 @@ interface AddAccountDialogProps {
|
||||
}
|
||||
|
||||
export function AddAccountDialog({ open, onClose, provider, displayName }: AddAccountDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const { refetch } = useCliproxyAuth();
|
||||
const [nickname, setNickname] = useState('');
|
||||
const startAuthMutation = useStartAuth();
|
||||
|
||||
const authCommand = `ccs ${provider} --auth --add`;
|
||||
|
||||
const copyCommand = async () => {
|
||||
await navigator.clipboard.writeText(authCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const handleStartAuth = () => {
|
||||
startAuthMutation.mutate(
|
||||
{ provider, nickname: nickname.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNickname('');
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
onClose();
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen && !startAuthMutation.isPending) {
|
||||
setNickname('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run the command below in your terminal to authenticate a new account
|
||||
Click the button below to authenticate a new account. A browser window will open for
|
||||
OAuth.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Run this command:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
|
||||
{authCommand}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" onClick={copyCommand}>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This will open your browser to authenticate with {displayName}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">Nickname (optional)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="e.g., work, personal"
|
||||
disabled={startAuthMutation.isPending}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this account. Auto-generated from email if left empty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'I ran the command'}
|
||||
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
|
||||
{startAuthMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Authenticate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{startAuthMutation.isPending && (
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Complete the OAuth flow in your browser...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* CLIProxy Stats Card Component
|
||||
*
|
||||
* Displays CLIProxyAPI usage statistics with:
|
||||
* - Status indicator (running/offline)
|
||||
* - Total requests with success rate ring
|
||||
* - Total tokens usage
|
||||
* - Model breakdown with usage bars
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Server, Zap, Cpu, Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
|
||||
interface CliproxyStatsCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) {
|
||||
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||
|
||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy not running
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full border-dashed', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
Offline
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Start a CLIProxy session (gemini, codex, agy) to collect stats.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error fetching stats
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full border-destructive/50', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<p className="text-xs text-destructive">{error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const totalRequests = stats?.totalRequests ?? 0;
|
||||
const failedRequests = stats?.quotaExceededCount ?? 0;
|
||||
const successRequests = totalRequests - failedRequests;
|
||||
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
|
||||
const totalTokens = stats?.tokens?.total ?? 0;
|
||||
|
||||
// Get model breakdown sorted by usage
|
||||
const models = Object.entries(stats?.requestsByModel ?? {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4); // Top 4 models
|
||||
|
||||
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
||||
<CardHeader className="px-3 py-2 border-b bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
CLIProxy Stats
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
<Zap className="h-3 w-3 mr-0.5" />
|
||||
Running
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Key metrics row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Requests with success ring */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||
<div className="relative">
|
||||
<svg className="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-muted/30"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${successRate * 0.88} 88`}
|
||||
strokeLinecap="round"
|
||||
className={successRate >= 90 ? 'text-green-500' : 'text-amber-500'}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">
|
||||
{successRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-bold leading-none">
|
||||
{formatNumber(totalRequests)}
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||
<div className="p-1.5 rounded-md bg-purple-100 dark:bg-purple-900/20">
|
||||
<Coins className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-bold leading-none">{formatNumber(totalTokens)}</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">Total tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model breakdown */}
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Cpu className="h-3 w-3" />
|
||||
Models Used
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map(([model, count]) => {
|
||||
const percentage = Math.round((count / maxModelRequests) * 100);
|
||||
const displayName = formatModelName(model);
|
||||
return (
|
||||
<div key={model} className="group">
|
||||
<div className="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span className="truncate font-medium" title={model}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">{count}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent/70 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format large numbers with K/M suffix */
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/** Format model names for display (remove prefixes, shorten) */
|
||||
function formatModelName(model: string): string {
|
||||
// Remove common prefixes
|
||||
let name = model
|
||||
.replace(/^gemini-claude-/, '')
|
||||
.replace(/^gemini-/, '')
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/^anthropic\./, '')
|
||||
.replace(/-thinking$/, ' Thinking');
|
||||
|
||||
// Capitalize first letter of each word
|
||||
name = name
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Shorten long names
|
||||
if (name.length > 20) {
|
||||
name = name.slice(0, 18) + '...';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
||||
@@ -17,33 +17,33 @@ const ANOMALY_CONFIG: Record<
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
high_input: {
|
||||
icon: Zap,
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
label: 'High Input',
|
||||
description: 'Unusually high input token usage detected.',
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-100 dark:bg-amber-900/20',
|
||||
label: 'High Input Volume',
|
||||
},
|
||||
high_io_ratio: {
|
||||
icon: Gauge,
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||
label: 'High I/O Ratio',
|
||||
description: 'Output tokens are significantly higher than input tokens.',
|
||||
},
|
||||
cost_spike: {
|
||||
icon: DollarSign,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
label: 'Cost Spike',
|
||||
description: 'Daily cost is significantly higher than average.',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/20',
|
||||
label: 'Cost Spike Detected',
|
||||
},
|
||||
high_cache_read: {
|
||||
icon: Database,
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
label: 'Heavy Caching',
|
||||
description: 'High volume of cache read operations.',
|
||||
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||
label: 'Heavy Cache Usage',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,114 +55,119 @@ export function UsageInsightsCard({
|
||||
}: UsageInsightsCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-4 py-3 border-b">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-muted-foreground" />
|
||||
Usage Insights
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 flex-1 flex items-center justify-center">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
|
||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
<Card
|
||||
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
|
||||
>
|
||||
<div className="px-1 py-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<SkeletonIcon />
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted animate-pulse shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-2 w-1/2 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnomalies = summary && summary.totalAnomalies > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
||||
<CardHeader className="px-4 py-3 border-b bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Lightbulb
|
||||
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
|
||||
/>
|
||||
Usage Insights
|
||||
</CardTitle>
|
||||
{hasAnomalies ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider"
|
||||
>
|
||||
Attention Needed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
Healthy
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 min-h-0 flex flex-col">
|
||||
{hasAnomalies ? (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="divide-y">
|
||||
{anomalies.map((anomaly, index) => {
|
||||
const config = ANOMALY_CONFIG[anomaly.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div key={index} className="p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-lg shrink-0 bg-muted/30',
|
||||
config.color
|
||||
.replace('text-', 'bg-')
|
||||
.replace('600', '100')
|
||||
.replace('400', '900/20')
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm">{config.label}</p>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{anomaly.date}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
{anomaly.model && (
|
||||
<div className="pt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1 py-0 h-5 font-mono"
|
||||
>
|
||||
{anomaly.model}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">No anomalies detected</p>
|
||||
<p className="text-xs mt-1 max-w-[200px]">
|
||||
Your usage patterns look normal for the selected period.
|
||||
</p>
|
||||
if (!hasAnomalies) {
|
||||
return (
|
||||
<Card
|
||||
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground min-h-[200px]">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3 ring-4 ring-green-50 dark:ring-green-900/10">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
|
||||
<p className="text-xs mt-1.5 max-w-[200px] leading-relaxed">
|
||||
Your usage patterns are within normal ranges for the selected period.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}>
|
||||
<div className="px-1 py-2 flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="font-semibold text-sm">Usage Insights</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-2 text-[10px] font-medium border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400"
|
||||
>
|
||||
{summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-2 px-2">
|
||||
<div className="space-y-1 pb-2">
|
||||
{anomalies.map((anomaly, index) => {
|
||||
const config = ANOMALY_CONFIG[anomaly.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group p-3 rounded-lg hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-md shrink-0 ring-1 ring-inset ring-black/5 dark:ring-white/10',
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', config.color)} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm text-foreground/90">{config.label}</p>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{anomaly.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
|
||||
{anomaly.model && (
|
||||
<div className="pt-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 font-mono bg-muted/50 text-muted-foreground group-hover:bg-background group-hover:text-foreground transition-colors"
|
||||
>
|
||||
{anomaly.model}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonIcon() {
|
||||
return <div className="w-4 h-4 bg-muted rounded-full animate-pulse" />;
|
||||
}
|
||||
|
||||
@@ -46,20 +46,20 @@ export function UsageTrendChart({
|
||||
}, [data, granularity]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-[300px] w-full', className)} />;
|
||||
return <Skeleton className={cn('h-full w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={cn('h-[300px] flex items-center justify-center', className)}>
|
||||
<div className={cn('h-full flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No usage data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<div className={cn('w-full h-full', className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -43,7 +44,16 @@ const navGroups = [
|
||||
title: 'Identity & Access',
|
||||
items: [
|
||||
{ path: '/api', icon: Key, label: 'API Profiles' },
|
||||
{ path: '/cliproxy', icon: Zap, label: 'CLIProxy' },
|
||||
{
|
||||
path: '/cliproxy',
|
||||
icon: Zap,
|
||||
label: 'CLIProxy',
|
||||
isCollapsible: true,
|
||||
children: [
|
||||
{ path: '/cliproxy', label: 'Overview' },
|
||||
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
icon: Users,
|
||||
@@ -69,12 +79,15 @@ export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const { state } = useSidebar();
|
||||
|
||||
// Helper to check if a route is active (including sub-routes if needed)
|
||||
// Helper to check if a route is active (exact match)
|
||||
const isRouteActive = (path: string) => location.pathname === path;
|
||||
|
||||
// Helper to check if a group/parent should be open based on active child
|
||||
// Also handles sub-routes (e.g., /cliproxy/control-panel matches /cliproxy)
|
||||
const isParentActive = (children: { path: string }[]) => {
|
||||
return children.some((child) => isRouteActive(child.path));
|
||||
return children.some(
|
||||
(child) => isRouteActive(child.path) || location.pathname.startsWith(child.path + '/')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* CLIProxy Stats Overview Component
|
||||
*
|
||||
* Full-width dashboard section showing comprehensive CLIProxyAPI statistics.
|
||||
* Features:
|
||||
* - Status indicator with uptime
|
||||
* - Request metrics with success rate visualization
|
||||
* - Token usage with cost estimation
|
||||
* - Model breakdown with usage distribution
|
||||
* - Session history
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Server,
|
||||
Zap,
|
||||
ZapOff,
|
||||
Activity,
|
||||
Coins,
|
||||
Cpu,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
|
||||
interface CliproxyStatsOverviewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
|
||||
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||
|
||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Offline state
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Session Statistics
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time usage metrics from CLIProxyAPI
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="w-fit gap-1.5">
|
||||
<ZapOff className="h-3.5 w-3.5" />
|
||||
Offline
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">No Active Session</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Start a CLIProxy session using{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
|
||||
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
|
||||
to view real-time statistics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Card className="border-destructive/50">
|
||||
<CardContent className="flex items-center gap-4 py-6">
|
||||
<XCircle className="h-8 w-8 text-destructive shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-medium">Failed to Load Statistics</h3>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate derived stats
|
||||
const totalRequests = stats?.totalRequests ?? 0;
|
||||
const failedRequests = stats?.quotaExceededCount ?? 0;
|
||||
const successRequests = totalRequests - failedRequests;
|
||||
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
|
||||
const totalTokens = stats?.tokens?.total ?? 0;
|
||||
|
||||
// Get model breakdown sorted by usage
|
||||
const models = Object.entries(stats?.requestsByModel ?? {}).sort((a, b) => b[1] - a[1]);
|
||||
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
|
||||
|
||||
// Define color palette for models
|
||||
const modelColors = [
|
||||
'bg-blue-500',
|
||||
'bg-purple-500',
|
||||
'bg-amber-500',
|
||||
'bg-emerald-500',
|
||||
'bg-rose-500',
|
||||
'bg-cyan-500',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Session Statistics
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Real-time usage metrics from CLIProxyAPI</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Running
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Requests Card */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
<span className="text-muted-foreground">{successRequests} success</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||
<TrendingUp className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Success Rate Card */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
|
||||
<p className="text-2xl font-bold">{successRate}%</p>
|
||||
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
successRate >= 90 ? 'bg-green-500' : 'bg-amber-500'
|
||||
)}
|
||||
style={{ width: `${successRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-lg',
|
||||
successRate >= 90
|
||||
? 'bg-green-100 dark:bg-green-900/20'
|
||||
: 'bg-amber-100 dark:bg-amber-900/20'
|
||||
)}
|
||||
>
|
||||
{successRate >= 90 ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tokens Card */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(totalTokens)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
~${estimateCost(totalTokens).toFixed(2)} estimated
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
|
||||
<Coins className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Models Card */}
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
|
||||
<p className="text-2xl font-bold">{models.length}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/20">
|
||||
<Cpu className="h-4 w-4 text-cyan-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Model Breakdown */}
|
||||
{models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
Model Usage Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{models.map(([model, count], index) => {
|
||||
const percentage = Math.round((count / totalRequests) * 100);
|
||||
const barPercentage = Math.round((count / maxModelRequests) * 100);
|
||||
const displayName = formatModelName(model);
|
||||
const colorClass = modelColors[index % modelColors.length];
|
||||
|
||||
return (
|
||||
<div key={model} className="group">
|
||||
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', colorClass)} />
|
||||
<span className="font-medium truncate" title={model}>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
|
||||
<span>{count} requests</span>
|
||||
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
colorClass
|
||||
)}
|
||||
style={{ width: `${barPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format large numbers with K/M suffix */
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/** Format model names for display */
|
||||
function formatModelName(model: string): string {
|
||||
let name = model
|
||||
.replace(/^gemini-claude-/, '')
|
||||
.replace(/^gemini-/, '')
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/^anthropic\./, '')
|
||||
.replace(/-thinking$/, ' Thinking');
|
||||
|
||||
name = name
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
if (name.length > 25) {
|
||||
name = name.slice(0, 23) + '...';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Estimate cost based on token count (rough average) */
|
||||
function estimateCost(tokens: number): number {
|
||||
// Average cost per 1M tokens across models (~$3 for input, ~$15 for output)
|
||||
// Assuming 30% input, 70% output ratio
|
||||
const avgCostPerMillion = 3 * 0.3 + 15 * 0.7;
|
||||
return (tokens / 1000000) * avgCostPerMillion;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Categorized Model Selector
|
||||
* Groups models by provider (owned_by) with model counts
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Provider display configuration */
|
||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string }> = {
|
||||
google: { name: 'Google (Gemini)', color: 'text-blue-600' },
|
||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600' },
|
||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' },
|
||||
antigravity: { name: 'Antigravity', color: 'text-purple-600' },
|
||||
other: { name: 'Other', color: 'text-gray-600' },
|
||||
};
|
||||
|
||||
/** Get display name for category */
|
||||
function getCategoryDisplay(category: string) {
|
||||
return (
|
||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
color: 'text-gray-600',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorizedModelSelectorProps {
|
||||
/** Models data from API */
|
||||
modelsData: CliproxyModelsResponse | undefined;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Currently selected model */
|
||||
value: string | undefined;
|
||||
/** Callback when model changes */
|
||||
onChange: (model: string) => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Custom width class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategorizedModelSelector({
|
||||
modelsData,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Select a model',
|
||||
className,
|
||||
}: CategorizedModelSelectorProps) {
|
||||
// Sort categories by model count (descending)
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!modelsData?.byCategory) return [];
|
||||
return Object.entries(modelsData.byCategory)
|
||||
.sort(([, a], [, b]) => b.length - a.length)
|
||||
.map(([category, models]) => ({
|
||||
category,
|
||||
display: getCategoryDisplay(category),
|
||||
models,
|
||||
}));
|
||||
}, [modelsData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-9 w-[280px]', className)} />;
|
||||
}
|
||||
|
||||
if (!modelsData || modelsData.totalCount === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
|
||||
<Cpu className="w-4 h-4" />
|
||||
<span>No models available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className={cn('w-[320px]', className)}>
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[400px]">
|
||||
{sortedCategories.map(({ category, display, models }) => (
|
||||
<SelectGroup key={category}>
|
||||
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className={cn('font-semibold', display.color)}>{display.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
||||
{models.length}
|
||||
</Badge>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id} className="pl-4">
|
||||
<span className="truncate">{model.id}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact variant for inline usage */
|
||||
export function CategorizedModelSelectorCompact({
|
||||
modelsData,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
|
||||
return (
|
||||
<CategorizedModelSelector
|
||||
modelsData={modelsData}
|
||||
isLoading={isLoading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder="Model..."
|
||||
className="w-[200px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* CLIProxy Header Component
|
||||
* Fixed header with OAuth login buttons, status indicator, and refresh
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LoginButtonProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isAuthenticated: boolean;
|
||||
accountCount: number;
|
||||
isAuthenticating: boolean;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
function LoginButton({
|
||||
displayName,
|
||||
isAuthenticated,
|
||||
accountCount,
|
||||
isAuthenticating,
|
||||
onLogin,
|
||||
}: LoginButtonProps) {
|
||||
if (isAuthenticating) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" disabled className="gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{displayName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-green-500/30 text-green-600 dark:text-green-400"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{displayName}
|
||||
{accountCount > 1 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
|
||||
{accountCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="default" size="sm" className="gap-2" onClick={onLogin}>
|
||||
+ {displayName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to format relative time
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes === 1) return '1m ago';
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
// Hook for relative time display that updates periodically
|
||||
function useRelativeTime(date: Date | undefined): string | null {
|
||||
const [text, setText] = useState<string | null>(() => (date ? formatRelativeTime(date) : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!date) return;
|
||||
|
||||
// Update every 30 seconds via interval only
|
||||
const interval = setInterval(() => {
|
||||
setText(formatRelativeTime(date));
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [date]);
|
||||
|
||||
// Compute current value on each render if date changes
|
||||
// This is the pure computation part
|
||||
const currentText = date ? formatRelativeTime(date) : null;
|
||||
|
||||
// Return the more recent of computed or state-based value
|
||||
// State value will be updated by interval
|
||||
return date ? currentText : text;
|
||||
}
|
||||
|
||||
interface CliproxyHeaderProps {
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
lastUpdated?: Date;
|
||||
isRunning?: boolean;
|
||||
}
|
||||
|
||||
export function CliproxyHeader({
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastUpdated,
|
||||
isRunning = true,
|
||||
}: CliproxyHeaderProps) {
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
|
||||
const lastUpdatedText = useRelativeTime(lastUpdated);
|
||||
|
||||
const providers = [
|
||||
{ id: 'claude', displayName: 'Claude' },
|
||||
{ id: 'gemini', displayName: 'Gemini' },
|
||||
{ id: 'codex', displayName: 'Codex' },
|
||||
{ id: 'agy', displayName: 'Agy' },
|
||||
];
|
||||
|
||||
const getProviderStatus = (providerId: string) => {
|
||||
const status = authData?.authStatus.find((s) => s.provider === providerId);
|
||||
return {
|
||||
isAuthenticated: status?.authenticated ?? false,
|
||||
accountCount: status?.accounts?.length ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4 border-b">
|
||||
{/* Top row: Title and Login Buttons */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage OAuth providers and configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Buttons - Wrap on mobile */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{providers.map((p) => {
|
||||
const status = getProviderStatus(p.id);
|
||||
return (
|
||||
<LoginButton
|
||||
key={p.id}
|
||||
provider={p.id}
|
||||
displayName={p.displayName}
|
||||
isAuthenticated={status.isAuthenticated}
|
||||
accountCount={status.accountCount}
|
||||
isAuthenticating={authProvider === p.id && isAuthenticating}
|
||||
onLogin={() => startAuth(p.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Status and Refresh */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={isRunning ? 'default' : 'secondary'} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
{isRunning ? 'Running' : 'Offline'}
|
||||
</Badge>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground">{lastUpdatedText}</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* CLIProxy Tabs Component
|
||||
* Tab navigation wrapper for Overview, Config, and Logs tabs
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { LayoutDashboard, FileCode, ScrollText } from 'lucide-react';
|
||||
|
||||
export type CliproxyTabValue = 'overview' | 'config' | 'logs';
|
||||
|
||||
interface CliproxyTabsProps {
|
||||
activeTab: CliproxyTabValue;
|
||||
onTabChange: (tab: CliproxyTabValue) => void;
|
||||
children: {
|
||||
overview: ReactNode;
|
||||
config: ReactNode;
|
||||
logs: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ value: 'overview' as const, label: 'Overview', icon: LayoutDashboard },
|
||||
{ value: 'config' as const, label: 'Config', icon: FileCode },
|
||||
{ value: 'logs' as const, label: 'Logs', icon: ScrollText },
|
||||
];
|
||||
|
||||
export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => onTabChange(v as CliproxyTabValue)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-md">
|
||||
{TAB_CONFIG.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
{children.overview}
|
||||
</TabsContent>
|
||||
<TabsContent value="config" className="mt-6">
|
||||
{children.config}
|
||||
</TabsContent>
|
||||
<TabsContent value="logs" className="mt-6">
|
||||
{children.logs}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Config Split View Container
|
||||
* Main split layout for Config tab with file tree and YAML editor
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Save, RotateCcw, GitCompare, Loader2 } from 'lucide-react';
|
||||
import { FileTree } from './file-tree';
|
||||
import { buildFileTree } from './file-tree-utils';
|
||||
import { YamlEditor, EditorStatusBar } from './yaml-editor';
|
||||
import { DiffDialog } from './diff-dialog';
|
||||
import { useCliproxyConfig, useCliproxyAuthFile } from '@/hooks/use-cliproxy-config';
|
||||
|
||||
export function ConfigSplitView() {
|
||||
const [selectedFile, setSelectedFile] = useState<string>('config.yaml');
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
const {
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
validation,
|
||||
isLoading,
|
||||
authFiles,
|
||||
updateContent,
|
||||
resetContent,
|
||||
saveContent,
|
||||
isSaving,
|
||||
} = useCliproxyConfig();
|
||||
|
||||
// For viewing auth files (read-only)
|
||||
const [viewingAuthFile, setViewingAuthFile] = useState<string | null>(null);
|
||||
const { data: authFileContent } = useCliproxyAuthFile(viewingAuthFile);
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
if (path === 'config.yaml') {
|
||||
setSelectedFile(path);
|
||||
setViewingAuthFile(null);
|
||||
} else if (path.startsWith('auths/')) {
|
||||
const fileName = path.replace('auths/', '');
|
||||
setSelectedFile(path);
|
||||
setViewingAuthFile(fileName);
|
||||
}
|
||||
};
|
||||
|
||||
// Build file tree
|
||||
const fileTree = buildFileTree(isDirty, authFiles);
|
||||
|
||||
// Keyboard shortcut for save
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (isDirty && validation.valid) {
|
||||
saveContent();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDirty, validation.valid, saveContent]);
|
||||
|
||||
const isEditingConfig = selectedFile === 'config.yaml';
|
||||
const currentContent = isEditingConfig ? content : (authFileContent ?? '');
|
||||
const isReadonly = !isEditingConfig;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[500px] border rounded-lg">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{selectedFile}</span>
|
||||
{isDirty && isEditingConfig && (
|
||||
<span className="text-xs text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
|
||||
Modified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isEditingConfig && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDiff(true)}
|
||||
disabled={!isDirty}
|
||||
className="h-7 gap-1.5"
|
||||
>
|
||||
<GitCompare className="w-3.5 h-3.5" />
|
||||
Diff
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetContent}
|
||||
disabled={!isDirty}
|
||||
className="h-7 gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={saveContent}
|
||||
disabled={!isDirty || !validation.valid || isSaving}
|
||||
className="h-7 gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Split Panels */}
|
||||
<PanelGroup direction="horizontal" className="h-[500px]">
|
||||
<Panel defaultSize={20} minSize={15} maxSize={30}>
|
||||
<FileTree files={fileTree} selectedFile={selectedFile} onSelect={handleFileSelect} />
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="w-1 bg-border hover:bg-accent transition-colors cursor-col-resize" />
|
||||
|
||||
<Panel>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<YamlEditor
|
||||
value={currentContent}
|
||||
onChange={updateContent}
|
||||
readonly={isReadonly}
|
||||
errorLine={validation.line}
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar validation={validation} isDirty={isDirty && isEditingConfig} />
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
|
||||
{/* Diff Dialog */}
|
||||
<DiffDialog
|
||||
open={showDiff}
|
||||
onClose={() => setShowDiff(false)}
|
||||
original={originalContent}
|
||||
modified={content}
|
||||
onConfirmSave={() => {
|
||||
saveContent();
|
||||
setShowDiff(false);
|
||||
}}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Diff Dialog Component
|
||||
* Shows before/after comparison before saving config
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface DiffDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
original: string;
|
||||
modified: string;
|
||||
onConfirmSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function DiffDialog({
|
||||
open,
|
||||
onClose,
|
||||
original,
|
||||
modified,
|
||||
onConfirmSave,
|
||||
isSaving,
|
||||
}: DiffDialogProps) {
|
||||
const originalLines = original.split('\n');
|
||||
const modifiedLines = modified.split('\n');
|
||||
|
||||
// Simple line-by-line diff
|
||||
const renderDiff = () => {
|
||||
const maxLines = Math.max(originalLines.length, modifiedLines.length);
|
||||
const rows = [];
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const origLine = originalLines[i] ?? '';
|
||||
const modLine = modifiedLines[i] ?? '';
|
||||
const isChanged = origLine !== modLine;
|
||||
|
||||
rows.push(
|
||||
<div key={i} className={`flex text-xs font-mono ${isChanged ? 'bg-amber-500/10' : ''}`}>
|
||||
<div className="w-8 text-right pr-2 text-muted-foreground/50 border-r">{i + 1}</div>
|
||||
<div className="flex-1 grid grid-cols-2">
|
||||
<div className={`px-2 ${isChanged ? 'bg-red-500/10 line-through' : ''}`}>
|
||||
{origLine}
|
||||
</div>
|
||||
<div className={`px-2 border-l ${isChanged ? 'bg-green-500/10' : ''}`}>{modLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Changes</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2 text-xs font-medium bg-muted p-2 border-b">
|
||||
<div className="text-red-600">Original</div>
|
||||
<div className="text-green-600 border-l pl-2">Modified</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y">{renderDiff()}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirmSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* File Tree Utilities
|
||||
* Helper functions for building file tree structure
|
||||
*/
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
children?: FileNode[];
|
||||
modified?: boolean;
|
||||
icon?: 'yaml' | 'json' | 'key';
|
||||
}
|
||||
|
||||
// Helper to build file tree from flat list
|
||||
export function buildFileTree(
|
||||
configModified: boolean,
|
||||
authFiles: Array<{ name: string; provider?: string }>
|
||||
): FileNode[] {
|
||||
return [
|
||||
{
|
||||
name: 'config',
|
||||
path: 'config',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{
|
||||
name: 'config.yaml',
|
||||
path: 'config.yaml',
|
||||
type: 'file',
|
||||
icon: 'yaml',
|
||||
modified: configModified,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'auths',
|
||||
path: 'auths',
|
||||
type: 'folder',
|
||||
children: authFiles.map((f) => ({
|
||||
name: f.name,
|
||||
path: `auths/${f.name}`,
|
||||
type: 'file' as const,
|
||||
icon: 'key' as const,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* File Tree Component
|
||||
* Left panel file browser for Config tab
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, FileText, Folder, Key } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { FileNode } from './file-tree-utils';
|
||||
|
||||
interface FileTreeProps {
|
||||
files: FileNode[];
|
||||
selectedFile: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
function FileIcon({ type, icon }: { type: 'file' | 'folder'; icon?: string }) {
|
||||
if (type === 'folder') {
|
||||
return <Folder className="w-4 h-4 text-amber-500" />;
|
||||
}
|
||||
if (icon === 'key') {
|
||||
return <Key className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
return <FileText className="w-4 h-4 text-blue-500" />;
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
selectedFile,
|
||||
onSelect,
|
||||
expandedFolders,
|
||||
onToggleFolder,
|
||||
}: {
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
selectedFile: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
expandedFolders: Set<string>;
|
||||
onToggleFolder: (path: string) => void;
|
||||
}) {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = selectedFile === node.path;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full px-2 py-1 text-sm text-left rounded-md transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
isSelected && 'bg-accent/10 border-l-2 border-accent'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (node.type === 'folder') {
|
||||
onToggleFolder(node.path);
|
||||
} else {
|
||||
onSelect(node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'folder' &&
|
||||
(isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
))}
|
||||
<FileIcon type={node.type} icon={node.icon} />
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
{node.modified && <span className="text-amber-500 ml-1">*</span>}
|
||||
</button>
|
||||
|
||||
{node.type === 'folder' && isExpanded && node.children && (
|
||||
<div className="animate-in slide-in-from-top-1 duration-150">
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedFile={selectedFile}
|
||||
onSelect={onSelect}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileTree({ files, selectedFile, onSelect }: FileTreeProps) {
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['config', 'auths']));
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{files.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFile={selectedFile}
|
||||
onSelect={onSelect}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* YAML Editor Component
|
||||
* Right panel YAML editor with syntax highlighting and validation
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface YamlEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
errorLine?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function YamlEditor({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
errorLine,
|
||||
className,
|
||||
}: YamlEditorProps) {
|
||||
const { isDark } = useTheme();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => (
|
||||
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language="yaml">
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
className={cn(
|
||||
'px-2',
|
||||
errorLine === i + 1 && 'bg-destructive/20 border-l-2 border-destructive'
|
||||
)}
|
||||
>
|
||||
<span className="inline-block w-8 text-right mr-4 text-muted-foreground/50 select-none text-xs">
|
||||
{i + 1}
|
||||
</span>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
),
|
||||
[isDark, errorLine]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border overflow-hidden bg-muted/30 h-full',
|
||||
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
||||
readonly && 'opacity-70',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={readonly ? () => {} : onChange}
|
||||
highlight={highlightCode}
|
||||
key={isDark ? 'dark' : 'light'}
|
||||
padding={12}
|
||||
disabled={readonly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
textareaClassName="focus:outline-none font-mono text-sm leading-6"
|
||||
preClassName="font-mono text-sm leading-6"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
validation: { valid: boolean; error?: string; line?: number };
|
||||
isDirty: boolean;
|
||||
cursorLine?: number;
|
||||
cursorCol?: number;
|
||||
}
|
||||
|
||||
export function EditorStatusBar({
|
||||
validation,
|
||||
isDirty,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
}: EditorStatusBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
{validation.valid ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
Valid YAML
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{validation.error}
|
||||
{validation.line && ` (line ${validation.line})`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isDirty && <span className="text-amber-500">Unsaved changes</span>}
|
||||
</div>
|
||||
|
||||
{cursorLine && cursorCol && (
|
||||
<span className="text-muted-foreground">
|
||||
Ln {cursorLine}, Col {cursorCol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* CLIProxy Control Panel Embed
|
||||
*
|
||||
* Embeds the CLIProxy management.html with auto-authentication.
|
||||
* Uses postMessage to inject credentials into the iframe.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
|
||||
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */
|
||||
const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
|
||||
interface ControlPanelEmbedProps {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
||||
|
||||
const managementUrl = `http://localhost:${port}/management.html`;
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy returned an error');
|
||||
}
|
||||
} catch {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy is not running');
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}, [port]);
|
||||
|
||||
// Handle iframe load - attempt to auto-login via postMessage
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
// Try to inject credentials via postMessage
|
||||
// The management.html needs to listen for this message
|
||||
// If it doesn't support it, user will see the login page
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
try {
|
||||
// Send credentials to iframe
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'ccs-auto-login',
|
||||
apiBase: `http://localhost:${port}`,
|
||||
managementKey: CCS_CONTROL_PANEL_SECRET,
|
||||
},
|
||||
`http://localhost:${port}`
|
||||
);
|
||||
} catch {
|
||||
// Cross-origin restriction - expected if not same origin
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
||||
}
|
||||
}
|
||||
}, [port]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsLoading(true);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = managementUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Show error state if CLIProxy is not running
|
||||
if (!isConnected && error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-semibold">CLIProxy Control Panel</h2>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center max-w-md px-8">
|
||||
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
|
||||
<AlertCircle className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a CLIProxy session with{' '}
|
||||
<code className="bg-muted px-1 rounded">ccs gemini</code> or run{' '}
|
||||
<code className="bg-muted px-1 rounded">ccs config</code> which auto-starts it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
ccs
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Credential Health List Component
|
||||
* Auth status indicators for CLIProxy Overview tab
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
|
||||
|
||||
interface CredentialRowProps {
|
||||
name: string;
|
||||
provider: string;
|
||||
status: CredentialStatus;
|
||||
statusMessage: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function CredentialRow({
|
||||
name,
|
||||
provider,
|
||||
status,
|
||||
statusMessage,
|
||||
email,
|
||||
expiresAt,
|
||||
onRefresh,
|
||||
}: CredentialRowProps) {
|
||||
const statusConfig = {
|
||||
ready: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bg: 'bg-green-500/10',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertCircle,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bg: 'bg-amber-500/10',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bg: 'bg-red-500/10',
|
||||
},
|
||||
disabled: {
|
||||
icon: MinusCircle,
|
||||
color: 'text-muted-foreground',
|
||||
bg: 'bg-muted',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
const formatExpiry = (date?: string) => {
|
||||
if (!date) return 'Never';
|
||||
const expiry = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = expiry.getTime() - now.getTime();
|
||||
if (diff < 0) return 'Expired';
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) return 'Soon';
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-2 rounded-full', config.bg)}>
|
||||
<Icon className={cn('w-4 h-4', config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{email ?? name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={
|
||||
status === 'ready' ? 'outline' : status === 'warning' ? 'secondary' : 'destructive'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{statusMessage}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Expires: {formatExpiry(expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
{status === 'warning' && onRefresh && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialHealthSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CredentialHealthList() {
|
||||
const { data: authData, isLoading } = useCliproxyAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <CredentialHealthSkeleton />;
|
||||
}
|
||||
|
||||
// Flatten accounts from all providers
|
||||
const credentials =
|
||||
authData?.authStatus.flatMap((status) =>
|
||||
(status.accounts ?? []).map((account) => ({
|
||||
name: account.id,
|
||||
provider: status.provider,
|
||||
status: (account as { status?: CredentialStatus }).status ?? 'ready',
|
||||
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
|
||||
email: account.email,
|
||||
expiresAt: (account as { expiresAt?: string }).expiresAt,
|
||||
}))
|
||||
) ?? [];
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No credentials configured. Use the login buttons above to authenticate.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{credentials.map((cred, i) => (
|
||||
<CredentialRow key={`${cred.provider}-${cred.name}-${i}`} {...cred} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Model Preferences Grid Component
|
||||
* Displays all available models categorized by provider source
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Cpu, AlertCircle } from 'lucide-react';
|
||||
import { useCliproxyModels } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Category display configuration */
|
||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
|
||||
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
|
||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
||||
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
||||
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
||||
};
|
||||
|
||||
function getCategoryDisplay(category: string) {
|
||||
return (
|
||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPreferencesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Available Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyModelsState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Available Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<AlertCircle className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-1">No models available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start a CLIProxy session to fetch available models
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPreferencesGrid() {
|
||||
const { data: modelsData, isLoading, isError } = useCliproxyModels();
|
||||
|
||||
// Sort categories by model count
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!modelsData?.byCategory) return [];
|
||||
return Object.entries(modelsData.byCategory)
|
||||
.sort(([, a], [, b]) => b.length - a.length)
|
||||
.map(([category, models]) => ({
|
||||
category,
|
||||
display: getCategoryDisplay(category),
|
||||
models,
|
||||
}));
|
||||
}, [modelsData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ModelPreferencesSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !modelsData || modelsData.totalCount === 0) {
|
||||
return <EmptyModelsState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Available Models
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modelsData.totalCount} total
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Models available through CLIProxyAPI, grouped by provider</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedCategories.map(({ category, display, models }) => (
|
||||
<div
|
||||
key={category}
|
||||
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={cn('font-medium text-sm', display.color)}>{display.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{models.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{models.slice(0, 5).map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
</div>
|
||||
))}
|
||||
{models.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
+{models.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Quick Stats Row Component
|
||||
* Compact stats display for CLIProxy Overview tab
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: StatCardProps) {
|
||||
return (
|
||||
<Card className="py-3">
|
||||
<CardContent className="p-0 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted text-muted-foreground">{icon}</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="py-3">
|
||||
<CardContent className="p-0 px-4">
|
||||
<div className="h-12 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickStatsRow() {
|
||||
const { data: stats, isLoading } = useCliproxyStats();
|
||||
|
||||
if (isLoading) {
|
||||
return <StatsSkeleton />;
|
||||
}
|
||||
|
||||
const usage = stats?.usage ?? {};
|
||||
const totalRequests = (usage.total_requests as number) ?? 0;
|
||||
const successCount = (usage.success_count as number) ?? 0;
|
||||
const totalTokens = (usage.total_tokens as number) ?? 0;
|
||||
|
||||
const successRate = totalRequests > 0 ? ((successCount / totalRequests) * 100).toFixed(1) : '0';
|
||||
|
||||
const tokenDisplay =
|
||||
totalTokens > 1000 ? `${(totalTokens / 1000).toFixed(1)}K` : String(totalTokens);
|
||||
|
||||
const apis = (usage.apis as Record<string, { models?: Record<string, unknown> }>) ?? {};
|
||||
const modelCount = Object.keys(apis).reduce((count, api) => {
|
||||
const apiData = apis[api];
|
||||
return count + Object.keys(apiData?.models ?? {}).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Total Requests"
|
||||
value={totalRequests}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CheckCircle2 className="w-4 h-4" />}
|
||||
label="Success Rate"
|
||||
value={`${successRate}%`}
|
||||
/>
|
||||
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} />
|
||||
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Provider Editor Component
|
||||
* Split-view editor for CLIProxy provider settings
|
||||
* Similar to ProfileEditor but tailored for provider configuration
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
Code2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Info,
|
||||
X,
|
||||
Shield,
|
||||
User,
|
||||
Plus,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { ProviderLogo } from './provider-logo';
|
||||
import { FlexibleModelSelector } from './provider-model-selector';
|
||||
import type { ProviderCatalog } from './provider-model-selector';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import {
|
||||
useCliproxyModels,
|
||||
usePresets,
|
||||
useCreatePreset,
|
||||
useDeletePreset,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
|
||||
);
|
||||
|
||||
interface SettingsResponse {
|
||||
profile: string;
|
||||
settings: {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
mtime: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ProviderEditorProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
authStatus: AuthStatus;
|
||||
catalog?: ProviderCatalog;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderEditor({
|
||||
provider,
|
||||
displayName,
|
||||
authStatus,
|
||||
catalog,
|
||||
logoProvider,
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
}: ProviderEditorProps) {
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch available models from CLIProxy API
|
||||
const { data: modelsData } = useCliproxyModels();
|
||||
|
||||
// Fetch saved presets for this provider
|
||||
const { data: presetsData } = usePresets(provider);
|
||||
const createPresetMutation = useCreatePreset();
|
||||
const deletePresetMutation = useDeletePreset();
|
||||
const savedPresets = presetsData?.presets || [];
|
||||
|
||||
// Get models for this provider based on owned_by field
|
||||
const providerModels = useMemo(() => {
|
||||
if (!modelsData?.models) return [];
|
||||
const ownerMap: Record<string, string[]> = {
|
||||
gemini: ['google'],
|
||||
agy: ['antigravity'],
|
||||
codex: ['openai'],
|
||||
qwen: ['alibaba', 'qwen'],
|
||||
iflow: ['iflow'],
|
||||
};
|
||||
const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
|
||||
return modelsData.models.filter((m) =>
|
||||
owners.some((o) => m.owned_by.toLowerCase().includes(o))
|
||||
);
|
||||
}, [modelsData, provider]);
|
||||
|
||||
// Fetch settings for this provider
|
||||
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
|
||||
queryKey: ['settings', provider],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/settings/${provider}/raw`);
|
||||
if (!res.ok) {
|
||||
// Return empty settings for unconfigured providers
|
||||
return {
|
||||
profile: provider,
|
||||
settings: { env: {} },
|
||||
mtime: Date.now(),
|
||||
path: `~/.ccs/profiles/${provider}/settings.json`,
|
||||
};
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const settings = data?.settings;
|
||||
|
||||
// Derive raw JSON content
|
||||
const rawJsonContent = useMemo(() => {
|
||||
if (rawJsonEdits !== null) return rawJsonEdits;
|
||||
if (settings) return JSON.stringify(settings, null, 2);
|
||||
return '{\n "env": {}\n}';
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
const handleRawJsonChange = useCallback((value: string) => {
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
// Parse current settings from JSON
|
||||
const currentSettings = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
return settings || { env: {} };
|
||||
}
|
||||
}, [rawJsonContent, settings]);
|
||||
|
||||
// Extract model values from settings
|
||||
const currentModel = currentSettings?.env?.ANTHROPIC_MODEL;
|
||||
const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||
const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||
const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
|
||||
// Update a setting value
|
||||
const updateEnvValue = (key: string, value: string) => {
|
||||
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||
const newSettings = { ...currentSettings, env: newEnv };
|
||||
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||
};
|
||||
|
||||
// Batch update multiple env values at once
|
||||
const updateEnvValues = (updates: Record<string, string>) => {
|
||||
const newEnv = { ...(currentSettings?.env || {}), ...updates };
|
||||
const newSettings = { ...currentSettings, env: newEnv };
|
||||
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||
};
|
||||
|
||||
// Check if JSON is valid
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(rawJsonContent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [rawJsonContent]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (rawJsonEdits === null) return false;
|
||||
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const settingsToSave = JSON.parse(rawJsonContent);
|
||||
const res = await fetch(`/api/settings/${provider}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
settings: settingsToSave,
|
||||
expectedMtime: data?.mtime,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 409) throw new Error('CONFLICT');
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
|
||||
setRawJsonEdits(null);
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error.message === 'CONFLICT') {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleConflictResolve = async (overwrite: boolean) => {
|
||||
setConflictDialog(false);
|
||||
if (overwrite) {
|
||||
await refetch();
|
||||
saveMutation.mutate();
|
||||
} else {
|
||||
setRawJsonEdits(null);
|
||||
}
|
||||
};
|
||||
|
||||
const accounts = authStatus.accounts || [];
|
||||
|
||||
// Render Left Column - Model Config + Info tabs
|
||||
const renderFriendlyUI = () => (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info & Usage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Model Config Tab */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Quick Presets */}
|
||||
{(catalog && catalog.models.length > 0) || savedPresets.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Recommended presets from catalog */}
|
||||
{catalog?.models.slice(0, 3).map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
});
|
||||
toast.success(`Applied "${model.name}" preset`);
|
||||
}}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
toast.success(`Applied "${preset.name}" preset`);
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePresetMutation.mutate({ profile: provider, name: preset.name });
|
||||
}}
|
||||
disabled={deletePresetMutation.isPending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border border-dashed"
|
||||
onClick={() => setCustomPresetOpen(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={currentModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={opusModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={sonnetModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={haikuModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Accounts Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Accounts
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{accounts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={onAddAccount}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<AccountItem
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
isRemoving={isRemovingAccount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
|
||||
<User className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No accounts connected</p>
|
||||
<p className="text-xs opacity-70">Add an account to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Info Tab */}
|
||||
<TabsContent
|
||||
value="info"
|
||||
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Provider Information */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
Provider Information
|
||||
</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Provider</span>
|
||||
<span className="font-mono">{displayName}</span>
|
||||
</div>
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">File Path</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||
{data.path}
|
||||
</code>
|
||||
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Last Modified</span>
|
||||
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Status</span>
|
||||
{authStatus.authenticated ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-fit text-green-600 border-green-200 bg-green-50"
|
||||
>
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Authenticated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="w-fit text-muted-foreground">
|
||||
Not connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Usage */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<UsageCommand
|
||||
label="Run with prompt"
|
||||
command={`ccs ${provider} "your prompt"`}
|
||||
/>
|
||||
<UsageCommand label="Change model" command={`ccs ${provider} --config`} />
|
||||
<UsageCommand label="Add account" command={`ccs ${provider} --add`} />
|
||||
<UsageCommand label="List accounts" command={`ccs ${provider} --accounts`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Right Column - Raw JSON Editor
|
||||
const renderRawEditor = () => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderLogo provider={logoProvider || provider} size="lg" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{displayName}</h2>
|
||||
{data && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.path.replace(/^.*\//, '')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => refetch()} disabled={isLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : (
|
||||
// Split Layout (40% Left / 60% Right)
|
||||
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
{/* Left Column: Friendly UI */}
|
||||
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
|
||||
|
||||
{/* Right Column: Raw Editor */}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Raw Configuration (JSON)
|
||||
</span>
|
||||
</div>
|
||||
{renderRawEditor()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
|
||||
{/* Custom Preset Dialog */}
|
||||
<CustomPresetDialog
|
||||
open={customPresetOpen}
|
||||
onClose={() => setCustomPresetOpen(false)}
|
||||
currentValues={{
|
||||
default: currentModel || '',
|
||||
opus: opusModel || '',
|
||||
sonnet: sonnetModel || '',
|
||||
haiku: haikuModel || '',
|
||||
}}
|
||||
onApply={(values, presetName) => {
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: values.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku,
|
||||
});
|
||||
toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`);
|
||||
setCustomPresetOpen(false);
|
||||
}}
|
||||
onSave={(values, presetName) => {
|
||||
if (!presetName) {
|
||||
toast.error('Please enter a preset name to save');
|
||||
return;
|
||||
}
|
||||
createPresetMutation.mutate({
|
||||
profile: provider,
|
||||
data: {
|
||||
name: presetName,
|
||||
default: values.default,
|
||||
opus: values.opus,
|
||||
sonnet: values.sonnet,
|
||||
haiku: values.haiku,
|
||||
},
|
||||
});
|
||||
setCustomPresetOpen(false);
|
||||
}}
|
||||
isSaving={createPresetMutation.isPending}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Account item component */
|
||||
function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{account.email || account.id}</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage command with copy button */
|
||||
function UsageCommand({ label, command }: { label: string; command: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||
{command}
|
||||
</code>
|
||||
<CopyButton value={command} size="icon" className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Custom Preset Dialog - Configure all model mappings at once */
|
||||
interface CustomPresetDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentValues: {
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
onApply: (
|
||||
values: { default: string; opus: string; sonnet: string; haiku: string },
|
||||
presetName?: string
|
||||
) => void;
|
||||
onSave?: (
|
||||
values: { default: string; opus: string; sonnet: string; haiku: string },
|
||||
presetName?: string
|
||||
) => void;
|
||||
isSaving?: boolean;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
}
|
||||
|
||||
function CustomPresetDialog({
|
||||
open,
|
||||
onClose,
|
||||
currentValues,
|
||||
onApply,
|
||||
onSave,
|
||||
isSaving,
|
||||
catalog,
|
||||
allModels,
|
||||
}: CustomPresetDialogProps) {
|
||||
const [values, setValues] = useState(currentValues);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Reset values when dialog opens with current values
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setValues(currentValues);
|
||||
setPresetName('');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Custom Preset
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preset-name">Preset Name (optional)</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="e.g., My Custom Config"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={values.default}
|
||||
onChange={(model) => setValues({ ...values, default: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={values.opus}
|
||||
onChange={(model) => setValues({ ...values, opus: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={values.sonnet}
|
||||
onChange={(model) => setValues({ ...values, sonnet: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={values.haiku}
|
||||
onChange={(model) => setValues({ ...values, haiku: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onSave(values, presetName || undefined)}
|
||||
disabled={isSaving || !presetName.trim()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Star className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Save Preset
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onApply(values, presetName || undefined)}>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
Apply Preset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Provider Logo Component
|
||||
* Uses actual provider logos with fallback to styled letters
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProviderLogoProps {
|
||||
provider: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/** Provider image assets mapping */
|
||||
const PROVIDER_IMAGES: Record<string, string> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
};
|
||||
|
||||
/** Provider color configuration (for fallback only - no background for image logos) */
|
||||
const PROVIDER_CONFIG: Record<string, { text: string; letter: string }> = {
|
||||
gemini: { text: 'text-blue-600', letter: 'G' },
|
||||
claude: { text: 'text-orange-600', letter: 'C' },
|
||||
codex: { text: 'text-emerald-600', letter: 'X' },
|
||||
agy: { text: 'text-violet-600', letter: 'A' },
|
||||
qwen: { text: 'text-cyan-600', letter: 'Q' },
|
||||
iflow: { text: 'text-indigo-600', letter: 'i' },
|
||||
};
|
||||
|
||||
/** Size configuration */
|
||||
const SIZE_CONFIG = {
|
||||
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' },
|
||||
md: { container: 'w-8 h-8', icon: 'w-5 h-5', text: 'text-sm' },
|
||||
lg: { container: 'w-12 h-12', icon: 'w-8 h-8', text: 'text-lg' },
|
||||
};
|
||||
|
||||
export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
|
||||
const providerKey = provider.toLowerCase();
|
||||
const config = PROVIDER_CONFIG[providerKey] || {
|
||||
text: 'text-gray-600',
|
||||
letter: provider[0]?.toUpperCase() || '?',
|
||||
};
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const imageSrc = PROVIDER_IMAGES[providerKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md',
|
||||
imageSrc && 'bg-white p-1',
|
||||
sizeConfig.container,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`${provider} logo`}
|
||||
className={cn(sizeConfig.icon, 'object-contain')}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline variant for use in text */
|
||||
export function ProviderLogoInline({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return <ProviderLogo provider={provider} size="sm" className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Provider Model Selector Component
|
||||
* Per-provider model selection using model-catalog.ts
|
||||
* Includes tier badges, broken/deprecated status indicators
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Model entry from catalog */
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
tier?: 'free' | 'paid';
|
||||
description?: string;
|
||||
broken?: boolean;
|
||||
issueUrl?: string;
|
||||
deprecated?: boolean;
|
||||
deprecationReason?: string;
|
||||
/** Optional preset mapping for different tiers (if different from id) */
|
||||
presetMapping?: {
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider catalog */
|
||||
export interface ProviderCatalog {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
models: ModelEntry[];
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
interface ProviderModelSelectorProps {
|
||||
/** Provider catalog data */
|
||||
catalog: ProviderCatalog | undefined;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Currently selected model */
|
||||
value: string | undefined;
|
||||
/** Callback when model changes */
|
||||
onChange: (model: string) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderModelSelector({
|
||||
catalog,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Select a model',
|
||||
className,
|
||||
}: ProviderModelSelectorProps) {
|
||||
// Group models by tier
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!catalog?.models) return { free: [], paid: [] };
|
||||
return {
|
||||
free: catalog.models.filter((m) => !m.tier || m.tier === 'free'),
|
||||
paid: catalog.models.filter((m) => m.tier === 'paid'),
|
||||
};
|
||||
}, [catalog]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
return catalog?.models.find((m) => m.id === value);
|
||||
}, [catalog, value]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-9 w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!catalog || catalog.models.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground py-2', className)}>
|
||||
No models available for this provider
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderModelItem = (model: ModelEntry) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className={cn('pl-4', model.broken && 'opacity-60', model.deprecated && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{model.broken && (
|
||||
<Badge variant="destructive" className="text-[9px] h-4 px-1">
|
||||
BROKEN
|
||||
</Badge>
|
||||
)}
|
||||
{model.deprecated && (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1">
|
||||
DEPRECATED
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{selectedModel && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{selectedModel.name}</span>
|
||||
{selectedModel.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedModels.free.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">Free Tier</SelectLabel>
|
||||
{groupedModels.free.map(renderModelItem)}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{groupedModels.paid.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-amber-600">Paid Tier</SelectLabel>
|
||||
{groupedModels.paid.map(renderModelItem)}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Warning for broken/deprecated models */}
|
||||
{selectedModel?.broken && (
|
||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">This model has known issues</p>
|
||||
{selectedModel.issueUrl && (
|
||||
<a
|
||||
href={selectedModel.issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
>
|
||||
View issue details
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModel?.deprecated && (
|
||||
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
|
||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">This model is deprecated</p>
|
||||
{selectedModel.deprecationReason && (
|
||||
<p className="opacity-80">{selectedModel.deprecationReason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model description */}
|
||||
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
|
||||
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Model Mapping Selector - For Opus/Sonnet/Haiku mapping */
|
||||
interface ModelMappingSelectorProps {
|
||||
catalog: ProviderCatalog | undefined;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
onChange: (model: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelMappingSelector({
|
||||
catalog,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ModelMappingSelectorProps) {
|
||||
if (!catalog) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catalog.models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm">{model.name}</span>
|
||||
{model.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flexible Model Selector - Combines catalog recommendations with full model list */
|
||||
interface FlexibleModelSelectorProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string | undefined;
|
||||
onChange: (model: string) => void;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
catalog,
|
||||
allModels,
|
||||
disabled,
|
||||
}: FlexibleModelSelectorProps) {
|
||||
// Combine catalog models (recommended) with all available models
|
||||
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{/* Recommended models from catalog */}
|
||||
{catalog && catalog.models.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
|
||||
{catalog.models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
{model.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* All available models (excluding already shown) */}
|
||||
{allModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">
|
||||
All Models ({allModels.length})
|
||||
</SelectLabel>
|
||||
{allModels
|
||||
.filter((m) => !catalogModelIds.has(m.id))
|
||||
.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Fallback if no models available */}
|
||||
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
|
||||
<div className="py-2 px-3 text-xs text-muted-foreground">No models available</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
@@ -74,18 +74,6 @@ export function CodeEditor({
|
||||
const { isDark } = useTheme();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isMasked, setIsMasked] = useState(true);
|
||||
// Force Editor remount when theme changes (works around react-simple-code-editor caching)
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip first render, only trigger on theme changes
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
setEditorKey((k) => k + 1);
|
||||
}, [isDark]);
|
||||
|
||||
// Validate on every change for JSON
|
||||
const validation = useMemo(() => {
|
||||
@@ -138,11 +126,7 @@ export function CodeEditor({
|
||||
// Reset flag on commas or new keys (handled by property check),
|
||||
// but persist through colons and whitespace
|
||||
else if (token.types.includes('punctuation')) {
|
||||
if (
|
||||
token.content !== ':' &&
|
||||
token.content !== '[' &&
|
||||
token.content !== '{'
|
||||
) {
|
||||
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +169,7 @@ export function CodeEditor({
|
||||
value={value}
|
||||
onValueChange={readonly ? () => {} : onChange}
|
||||
highlight={highlightCode}
|
||||
key={editorKey}
|
||||
key={isDark ? 'dark-editor' : 'light-editor'}
|
||||
padding={12}
|
||||
disabled={readonly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@@ -17,6 +17,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
@@ -26,9 +33,12 @@ import {
|
||||
Terminal,
|
||||
User,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useCliproxyAuth, useCreateVariant } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
interface QuickSetupWizardProps {
|
||||
open: boolean;
|
||||
@@ -53,9 +63,11 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); // Track explicit "add account" action
|
||||
|
||||
const { data: authData, refetch } = useCliproxyAuth();
|
||||
const createMutation = useCreateVariant();
|
||||
const startAuthMutation = useStartAuth();
|
||||
|
||||
// Get auth status for selected provider
|
||||
const providerAuth = authData?.authStatus.find(
|
||||
@@ -73,26 +85,22 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
setVariantName('');
|
||||
setModelName('');
|
||||
setCopied(false);
|
||||
setIsAddingNewAccount(false);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-advance from auth step when account detected
|
||||
// Use timeout to avoid synchronous setState in effect (React lint rule)
|
||||
// BUT only if user didn't explicitly click "Add new account"
|
||||
useEffect(() => {
|
||||
if (step === 'auth' && accounts.length > 0) {
|
||||
if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) {
|
||||
const timer = setTimeout(() => {
|
||||
if (accounts.length === 1) {
|
||||
setSelectedAccount(accounts[0]);
|
||||
setStep('variant');
|
||||
} else {
|
||||
setStep('account');
|
||||
}
|
||||
setStep('account');
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [step, accounts]);
|
||||
}, [step, accounts, isAddingNewAccount]);
|
||||
|
||||
const copyCommand = async (cmd: string) => {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
@@ -106,17 +114,32 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const handleStartAuth = () => {
|
||||
startAuthMutation.mutate(
|
||||
{ provider: selectedProvider },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// Account created, select it and advance to variant step
|
||||
if (data.account) {
|
||||
setSelectedAccount(data.account as OAuthAccount);
|
||||
setStep('variant');
|
||||
}
|
||||
refetch(); // Refresh auth status
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleProviderSelect = (providerId: string) => {
|
||||
setSelectedProvider(providerId);
|
||||
const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId);
|
||||
const provAccounts = auth?.accounts || [];
|
||||
|
||||
if (provAccounts.length === 0) {
|
||||
// No accounts - go to auth step to add first account
|
||||
setStep('auth');
|
||||
} else if (provAccounts.length === 1) {
|
||||
setSelectedAccount(provAccounts[0]);
|
||||
setStep('variant');
|
||||
} else {
|
||||
// Has accounts - always show account selection (includes "Add new account" option)
|
||||
setStep('account');
|
||||
}
|
||||
};
|
||||
@@ -152,9 +175,36 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
};
|
||||
const currentProgress = getStepProgress(step);
|
||||
|
||||
// Prevent accidental close when user has made progress
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
// Allow closing from success step or provider step (no progress yet)
|
||||
if (step === 'success' || step === 'provider') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// For other steps, require explicit close via Cancel/Back
|
||||
// The X button still works, but clicking outside doesn't close
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg"
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent closing on outside click when user has made progress
|
||||
if (step !== 'success' && step !== 'provider') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent ESC close during auth or variant creation
|
||||
if (startAuthMutation.isPending || createMutation.isPending) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
@@ -192,6 +242,48 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
{/* Step: Authentication */}
|
||||
{step === 'auth' && (
|
||||
<div className="space-y-4">
|
||||
{/* Primary: OAuth Button */}
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add
|
||||
an account
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleStartAuth}
|
||||
disabled={startAuthMutation.isPending}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{startAuthMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Authenticate in Browser
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{startAuthMutation.isPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Complete the OAuth flow in your browser...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary: CLI Command */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -210,21 +302,25 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This will open your browser to authenticate with{' '}
|
||||
{providers.find((p) => p.id === selectedProvider)?.name}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep('provider')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep('provider')}
|
||||
disabled={startAuthMutation.isPending}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || startAuthMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'I ran the command'}
|
||||
{isRefreshing ? 'Checking...' : 'Refresh Status'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,15 +329,23 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
{/* Step: Account Selection */}
|
||||
{step === 'account' && (
|
||||
<div className="space-y-4">
|
||||
{/* Existing accounts header */}
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Select an account ({accounts.length})
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{accounts.map((acc: OAuthAccount) => (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => handleAccountSelect(acc)}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="w-5 h-5 text-muted-foreground" />
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{acc.email || acc.id}</div>
|
||||
{acc.isDefault && (
|
||||
@@ -253,10 +357,41 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep('auth')}>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add new account button - more prominent */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-dashed border-primary/50 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setIsAddingNewAccount(true);
|
||||
setStep('auth');
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ExternalLink className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-primary">Add new account</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Authenticate with a different account
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep('provider')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Add different account
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,30 +421,54 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-name">Model (optional)</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="e.g., gemini-2.5-pro"
|
||||
/>
|
||||
<Label>Model</Label>
|
||||
<Select value={modelName} onValueChange={setModelName}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_CATALOGS[selectedProvider]?.models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{m.name}</span>
|
||||
{m.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
- {m.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => (accounts.length > 1 ? setStep('account') : setStep('auth'))}
|
||||
onClick={() => (accounts.length > 0 ? setStep('account') : setStep('provider'))}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateVariant}
|
||||
disabled={!variantName || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateVariant}
|
||||
disabled={!variantName || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Skip if you just wanted to add an account without creating a variant
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* OAuth Auth Flow Hook for CLIProxy
|
||||
* Manages popup-based OAuth authentication flows
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AuthFlowState {
|
||||
provider: string | null;
|
||||
isAuthenticating: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AUTH_ENDPOINTS: Record<string, string> = {
|
||||
claude: '/anthropic-auth-url',
|
||||
gemini: '/gemini-cli-auth-url',
|
||||
codex: '/codex-auth-url',
|
||||
agy: '/antigravity-auth-url',
|
||||
};
|
||||
|
||||
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
export function useCliproxyAuthFlow() {
|
||||
const [state, setState] = useState<AuthFlowState>({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
popupRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
}, [cleanup]);
|
||||
|
||||
const startAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
const endpoint = AUTH_ENDPOINTS[provider];
|
||||
if (!endpoint) {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: `Unknown provider: ${provider}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ provider, isAuthenticating: true, error: null });
|
||||
|
||||
try {
|
||||
// Get auth URL from API
|
||||
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get auth URL: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { url, state: authState } = data;
|
||||
|
||||
if (!url) {
|
||||
throw new Error('No auth URL returned from server');
|
||||
}
|
||||
|
||||
// Open popup
|
||||
const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes');
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Popup blocked. Please allow popups for this site.');
|
||||
}
|
||||
|
||||
popupRef.current = popup;
|
||||
|
||||
// Poll for completion
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
// Check if popup was closed by user
|
||||
if (popup.closed) {
|
||||
cleanup();
|
||||
// Check final status
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
} else {
|
||||
// User closed popup before completing
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication cancelled',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Failed to check auth status',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll status while popup is open
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
cleanup();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
cleanup();
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
}
|
||||
// 'wait' status means keep polling
|
||||
} catch {
|
||||
// Silently ignore polling errors, will retry
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
cleanup();
|
||||
toast.error('Authentication timed out');
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication timed out',
|
||||
});
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||
toast.error(message);
|
||||
setState({ provider: null, isAuthenticating: false, error: message });
|
||||
}
|
||||
},
|
||||
[cleanup, queryClient]
|
||||
);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
cleanup();
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startAuth,
|
||||
cancelAuth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* CLIProxy Config Hook
|
||||
* Manages config.yaml loading, editing, validation, and saving
|
||||
*
|
||||
* Uses a controlled editing pattern where edits are tracked separately
|
||||
* from server state to comply with React Compiler rules.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useReducer } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { api } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
type EditAction =
|
||||
| { type: 'EDIT'; content: string; serverContent: string }
|
||||
| { type: 'RESET'; serverContent: string }
|
||||
| { type: 'SAVE_SUCCESS'; content: string };
|
||||
|
||||
interface EditState {
|
||||
// Local edit content (empty means using server content)
|
||||
localContent: string | null;
|
||||
// Last known server content for dirty detection
|
||||
lastServerContent: string;
|
||||
}
|
||||
|
||||
function editReducer(state: EditState, action: EditAction): EditState {
|
||||
switch (action.type) {
|
||||
case 'EDIT':
|
||||
// If editing back to server content, clear local state
|
||||
if (action.content === action.serverContent) {
|
||||
return { localContent: null, lastServerContent: action.serverContent };
|
||||
}
|
||||
return { localContent: action.content, lastServerContent: action.serverContent };
|
||||
case 'RESET':
|
||||
return { localContent: null, lastServerContent: action.serverContent };
|
||||
case 'SAVE_SUCCESS':
|
||||
return { localContent: null, lastServerContent: action.content };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function validateYaml(code: string): ValidationResult {
|
||||
if (!code.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
try {
|
||||
parseYaml(code);
|
||||
return { valid: true };
|
||||
} catch (e: unknown) {
|
||||
const error = e as { linePos?: Array<{ line: number; col: number }>; message: string };
|
||||
const linePos = error.linePos?.[0];
|
||||
return {
|
||||
valid: false,
|
||||
error: error.message,
|
||||
line: linePos?.line,
|
||||
column: linePos?.col,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useCliproxyConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch config.yaml - server state
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['cliproxy-config-yaml'],
|
||||
queryFn: () => api.cliproxy.getConfigYaml(),
|
||||
});
|
||||
|
||||
// Fetch auth files list
|
||||
const authFilesQuery = useQuery({
|
||||
queryKey: ['cliproxy-auth-files'],
|
||||
queryFn: () => api.cliproxy.getAuthFiles(),
|
||||
});
|
||||
|
||||
// Server content
|
||||
const serverContent = configQuery.data ?? '';
|
||||
|
||||
// Edit state - tracks local edits separate from server
|
||||
const [editState, dispatch] = useReducer(editReducer, {
|
||||
localContent: null,
|
||||
lastServerContent: '',
|
||||
});
|
||||
|
||||
// Derived: current content (local edit or server)
|
||||
const content = editState.localContent ?? serverContent;
|
||||
|
||||
// Derived: dirty flag
|
||||
const isDirty = editState.localContent !== null && editState.localContent !== serverContent;
|
||||
|
||||
// Derived: validation
|
||||
const validation = useMemo(() => validateYaml(content), [content]);
|
||||
|
||||
// Update content
|
||||
const updateContent = useCallback(
|
||||
(newContent: string) => {
|
||||
dispatch({ type: 'EDIT', content: newContent, serverContent });
|
||||
},
|
||||
[serverContent]
|
||||
);
|
||||
|
||||
// Reset to server content
|
||||
const resetContent = useCallback(() => {
|
||||
dispatch({ type: 'RESET', serverContent });
|
||||
}, [serverContent]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (contentToSave: string) => api.cliproxy.saveConfigYaml(contentToSave),
|
||||
onSuccess: (_data, variables) => {
|
||||
dispatch({ type: 'SAVE_SUCCESS', content: variables });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-config-yaml'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
toast.success('Configuration saved successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Save handler
|
||||
const saveContent = useCallback(() => {
|
||||
if (!validation.valid) {
|
||||
toast.error('Cannot save invalid YAML');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(content);
|
||||
}, [content, validation.valid, saveMutation]);
|
||||
|
||||
return {
|
||||
// State
|
||||
content,
|
||||
originalContent: serverContent,
|
||||
isDirty,
|
||||
validation,
|
||||
|
||||
// Queries
|
||||
isLoading: configQuery.isLoading,
|
||||
isError: configQuery.isError,
|
||||
authFiles: authFilesQuery.data?.files ?? [],
|
||||
|
||||
// Actions
|
||||
updateContent,
|
||||
resetContent,
|
||||
saveContent,
|
||||
isSaving: saveMutation.isPending,
|
||||
|
||||
// Refresh
|
||||
refetch: configQuery.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCliproxyAuthFile(fileName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-auth-file', fileName],
|
||||
queryFn: () => (fileName ? api.cliproxy.getAuthFile(fileName) : null),
|
||||
enabled: Boolean(fileName),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* CLIProxy Logs Hook
|
||||
* Manages log streaming with buffering and throttling
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
provider?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface LogsState {
|
||||
logs: LogEntry[];
|
||||
isPaused: boolean;
|
||||
isConnected: boolean;
|
||||
lastTimestamp: string | null;
|
||||
stats: {
|
||||
total: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_LOGS = 500;
|
||||
const FLUSH_INTERVAL = 100; // ms
|
||||
const POLL_INTERVAL = 1000; // ms
|
||||
|
||||
export function useCliproxyLogs() {
|
||||
const [state, setState] = useState<LogsState>({
|
||||
logs: [],
|
||||
isPaused: false,
|
||||
isConnected: false,
|
||||
lastTimestamp: null,
|
||||
stats: { total: 0, errors: 0, warnings: 0 },
|
||||
});
|
||||
|
||||
const bufferRef = useRef<LogEntry[]>([]);
|
||||
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flushIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isPausedRef = useRef(false);
|
||||
const lastTimestampRef = useRef<string | null>(null);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
isPausedRef.current = state.isPaused;
|
||||
}, [state.isPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
lastTimestampRef.current = state.lastTimestamp;
|
||||
}, [state.lastTimestamp]);
|
||||
|
||||
// Flush buffer to state
|
||||
const flushBuffer = useCallback(() => {
|
||||
if (bufferRef.current.length === 0) return;
|
||||
if (isPausedRef.current) return;
|
||||
|
||||
const newLogs = bufferRef.current;
|
||||
bufferRef.current = [];
|
||||
|
||||
setState((prev) => {
|
||||
const combined = [...prev.logs, ...newLogs];
|
||||
const trimmed = combined.slice(-MAX_LOGS);
|
||||
|
||||
const errorCount = trimmed.filter((l) => l.level === 'error').length;
|
||||
const warnCount = trimmed.filter((l) => l.level === 'warn').length;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
logs: trimmed,
|
||||
stats: {
|
||||
total: trimmed.length,
|
||||
errors: errorCount,
|
||||
warnings: warnCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Poll for new logs
|
||||
const pollLogs = useCallback(async () => {
|
||||
if (isPausedRef.current) return;
|
||||
|
||||
try {
|
||||
const after = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString();
|
||||
const response = await fetch(`/api/cliproxy/logs?after=${encodeURIComponent(after)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const entries: LogEntry[] = data.logs ?? [];
|
||||
|
||||
if (entries.length > 0) {
|
||||
bufferRef.current.push(...entries);
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
lastTimestamp: lastEntry.timestamp,
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, isConnected: true }));
|
||||
}
|
||||
} catch {
|
||||
setState((prev) => ({ ...prev, isConnected: false }));
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
if (!isPausedRef.current) {
|
||||
pollTimeoutRef.current = setTimeout(pollLogs, POLL_INTERVAL);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start polling on mount
|
||||
useEffect(() => {
|
||||
// Start flush interval
|
||||
flushIntervalRef.current = setInterval(flushBuffer, FLUSH_INTERVAL);
|
||||
// Start polling
|
||||
pollLogs();
|
||||
|
||||
return () => {
|
||||
if (flushIntervalRef.current) {
|
||||
clearInterval(flushIntervalRef.current);
|
||||
}
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [flushBuffer, pollLogs]);
|
||||
|
||||
// Actions
|
||||
const pause = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isPaused: true }));
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
pollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isPaused: false }));
|
||||
pollLogs();
|
||||
}, [pollLogs]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
bufferRef.current = [];
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: [],
|
||||
stats: { total: 0, errors: 0, warnings: 0 },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
logs: state.logs,
|
||||
isPaused: state.isPaused,
|
||||
isConnected: state.isConnected,
|
||||
stats: state.stats,
|
||||
pause,
|
||||
resume,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter hook for search and level filtering
|
||||
export function useLogsFilter(logs: LogEntry[]) {
|
||||
const [levelFilter, setLevelFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
// Level filter
|
||||
if (levelFilter !== 'all' && log.level !== levelFilter) {
|
||||
return false;
|
||||
}
|
||||
// Search filter
|
||||
if (searchQuery && !log.message.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
filteredLogs,
|
||||
levelFilter,
|
||||
setLevelFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* React Query hook for CLIProxyAPI stats
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
/** CLIProxy usage statistics */
|
||||
export interface CliproxyStats {
|
||||
totalRequests: number;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
};
|
||||
requestsByModel: Record<string, number>;
|
||||
requestsByProvider: Record<string, number>;
|
||||
quotaExceededCount: number;
|
||||
retryCount: number;
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
/** CLIProxy running status */
|
||||
export interface CliproxyStatus {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy stats from API
|
||||
*/
|
||||
async function fetchCliproxyStats(): Promise<CliproxyStats> {
|
||||
const response = await fetch('/api/cliproxy/stats');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch stats');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy running status
|
||||
*/
|
||||
async function fetchCliproxyStatus(): Promise<CliproxyStatus> {
|
||||
const response = await fetch('/api/cliproxy/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch status');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get CLIProxy running status
|
||||
*/
|
||||
export function useCliproxyStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-status'],
|
||||
queryFn: fetchCliproxyStatus,
|
||||
refetchInterval: 10000, // Check every 10 seconds
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get CLIProxy usage stats
|
||||
*/
|
||||
export function useCliproxyStats(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-stats'],
|
||||
queryFn: fetchCliproxyStats,
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
retry: 1,
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/** CLIProxy model from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Categorized models response */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy models from API
|
||||
*/
|
||||
async function fetchCliproxyModels(): Promise<CliproxyModelsResponse> {
|
||||
const response = await fetch('/api/cliproxy/models');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch models');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get available CLIProxy models (categorized by provider)
|
||||
*/
|
||||
export function useCliproxyModels(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-models'],
|
||||
queryFn: fetchCliproxyModels,
|
||||
enabled,
|
||||
staleTime: 60000, // Models don't change often, cache for 1 minute
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type CreateVariant, type UpdateVariant } from '@/lib/api-client';
|
||||
import { api, type CreateVariant, type UpdateVariant, type CreatePreset } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useCliproxy() {
|
||||
@@ -18,7 +18,7 @@ export function useCliproxy() {
|
||||
export function useCliproxyAuth() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-auth'],
|
||||
queryFn: () => api.cliproxy.auth(),
|
||||
queryFn: () => api.cliproxy.getAuthStatus(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,3 +117,95 @@ export function useRemoveAccount() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth flow hook
|
||||
export function useStartAuth() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) =>
|
||||
api.cliproxy.auth.start(provider, nickname),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`Account added for ${variables.provider}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Stats and models hooks for Overview tab
|
||||
export function useCliproxyStats() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-stats'],
|
||||
queryFn: () => api.cliproxy.stats(),
|
||||
refetchInterval: 30000, // Refresh every 30s
|
||||
});
|
||||
}
|
||||
|
||||
export function useCliproxyModels() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-models'],
|
||||
queryFn: () => api.cliproxy.models(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, model }: { provider: string; model: string }) =>
|
||||
api.cliproxy.updateModel(provider, model),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
toast.success('Model updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Presets ====================
|
||||
|
||||
export function usePresets(profile: string) {
|
||||
return useQuery({
|
||||
queryKey: ['presets', profile],
|
||||
queryFn: () => api.presets.list(profile),
|
||||
enabled: !!profile,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ profile, data }: { profile: string; data: CreatePreset }) =>
|
||||
api.presets.create(profile, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
|
||||
toast.success(`Preset "${variables.data.name}" saved`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ profile, name }: { profile: string; name: string }) =>
|
||||
api.presets.delete(profile, name),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
|
||||
toast.success('Preset deleted');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,6 +86,27 @@ export interface AuthStatus {
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
/** Auth file info for Config tab */
|
||||
export interface AuthFile {
|
||||
name: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
/** CLIProxy model from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Categorized models response from CLIProxyAPI */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** Provider accounts summary */
|
||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||
|
||||
@@ -116,6 +137,23 @@ export interface SecretsExists {
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** Model preset for quick model switching */
|
||||
export interface ModelPreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
export interface CreatePreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus?: string;
|
||||
sonnet?: string;
|
||||
haiku?: string;
|
||||
}
|
||||
|
||||
// API
|
||||
export const api = {
|
||||
profiles: {
|
||||
@@ -134,7 +172,7 @@ export const api = {
|
||||
},
|
||||
cliproxy: {
|
||||
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
||||
auth: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
|
||||
getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
|
||||
create: (data: CreateVariant) =>
|
||||
request('/cliproxy', {
|
||||
method: 'POST',
|
||||
@@ -146,6 +184,44 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||
updateModel: (provider: string, model: string) =>
|
||||
request(`/cliproxy/models/${provider}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ model }),
|
||||
}),
|
||||
|
||||
// Config YAML for Config tab
|
||||
getConfigYaml: async (): Promise<string> => {
|
||||
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`);
|
||||
if (!res.ok) throw new Error('Failed to load config');
|
||||
return res.text();
|
||||
},
|
||||
saveConfigYaml: async (content: string): Promise<void> => {
|
||||
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/yaml' },
|
||||
body: content,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: 'Failed to save config' }));
|
||||
throw new Error(error.error || 'Failed to save config');
|
||||
}
|
||||
},
|
||||
|
||||
// Auth files for Config tab
|
||||
getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'),
|
||||
getAuthFile: async (name: string): Promise<string> => {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load auth file');
|
||||
return res.text();
|
||||
},
|
||||
|
||||
// Multi-account management
|
||||
accounts: {
|
||||
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
|
||||
@@ -159,6 +235,15 @@ export const api = {
|
||||
remove: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
},
|
||||
// OAuth flow
|
||||
auth: {
|
||||
/** Start OAuth flow - opens browser for authentication */
|
||||
start: (provider: string, nickname?: string) =>
|
||||
request<{ success: boolean; account: OAuthAccount }>(`/cliproxy/auth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nickname }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
|
||||
@@ -193,4 +278,17 @@ export const api = {
|
||||
}),
|
||||
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
|
||||
},
|
||||
/** Model presets for quick model switching */
|
||||
presets: {
|
||||
list: (profile: string) => request<{ presets: ModelPreset[] }>(`/settings/${profile}/presets`),
|
||||
create: (profile: string, data: CreatePreset) =>
|
||||
request<{ preset: ModelPreset }>(`/settings/${profile}/presets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (profile: string, name: string) =>
|
||||
request<{ success: boolean }>(`/settings/${profile}/presets/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Model Catalogs for CLIProxy providers
|
||||
* Shared data for Quick Setup Wizard and Provider Editor
|
||||
*/
|
||||
|
||||
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
|
||||
|
||||
/** Model catalog data - mirrors src/cliproxy/model-catalog.ts */
|
||||
export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
defaultModel: 'gemini-claude-opus-4-5-thinking',
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-claude-opus-4-5-thinking',
|
||||
name: 'Claude Opus 4.5 Thinking',
|
||||
description: 'Most capable, extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
||||
name: 'Claude Sonnet 4.5 Thinking',
|
||||
description: 'Balanced with extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description: 'Fast and capable',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
name: 'Gemini 3 Pro',
|
||||
description: 'Google latest model via Antigravity',
|
||||
},
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
name: 'Gemini 3 Pro',
|
||||
tier: 'paid',
|
||||
description: 'Latest model, requires paid Google account',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
description: 'Stable, works with free Google account',
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
defaultModel: 'gpt-5.1-codex-max',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'Codex Max (5.1)',
|
||||
description: 'Most capable Codex model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max-high',
|
||||
sonnet: 'gpt-5.1-codex-max',
|
||||
haiku: 'gpt-5.1-codex-mini-high',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT 5.2',
|
||||
description: 'Latest GPT model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2',
|
||||
opus: 'gpt-5.2',
|
||||
sonnet: 'gpt-5.2',
|
||||
haiku: 'gpt-5.2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'Codex Mini',
|
||||
description: 'Fast and efficient Codex model',
|
||||
},
|
||||
],
|
||||
},
|
||||
qwen: {
|
||||
provider: 'qwen',
|
||||
displayName: 'Qwen',
|
||||
defaultModel: 'qwen-coder-plus',
|
||||
models: [
|
||||
{
|
||||
id: 'qwen-coder-plus',
|
||||
name: 'Qwen Coder Plus',
|
||||
description: 'Alibaba code-focused model',
|
||||
},
|
||||
{
|
||||
id: 'qwen-max',
|
||||
name: 'Qwen Max',
|
||||
description: 'Most capable Qwen model',
|
||||
},
|
||||
],
|
||||
},
|
||||
iflow: {
|
||||
provider: 'iflow',
|
||||
displayName: 'iFlow',
|
||||
defaultModel: 'iflow-default',
|
||||
models: [
|
||||
{
|
||||
id: 'iflow-default',
|
||||
name: 'iFlow Default',
|
||||
description: 'Default iFlow model',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
+12
-18
@@ -18,7 +18,7 @@ import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
useModelUsage,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
useUsageInsights,
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
@@ -73,7 +72,6 @@ export function AnalyticsPage() {
|
||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
@@ -96,7 +94,7 @@ export function AnalyticsPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-5.5rem)] overflow-hidden p-4 gap-3">
|
||||
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
@@ -114,6 +112,7 @@ export function AnalyticsPage() {
|
||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
@@ -135,22 +134,22 @@ export function AnalyticsPage() {
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-4">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
|
||||
<CardHeader className="px-3 py-2 shrink-0">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Usage Trends
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
|
||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
|
||||
{/* Cost by Model - 4/10 width with breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||
<CardHeader className="px-3 py-2">
|
||||
@@ -291,13 +290,8 @@ export function AnalyticsPage() {
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
|
||||
{/* Usage Insights - 2/10 width */}
|
||||
<UsageInsightsCard
|
||||
anomalies={insights?.anomalies}
|
||||
summary={insights?.summary}
|
||||
isLoading={isInsightsLoading}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
{/* CLIProxy Stats - 2/10 width */}
|
||||
<CliproxyStatsCard className="lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
{/* Model Details Popover - positioned at cursor */}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* CLIProxy Control Panel Page
|
||||
*
|
||||
* Dedicated page for the CLIProxy management panel.
|
||||
*/
|
||||
|
||||
import { ControlPanelEmbed } from '@/components/cliproxy/control-panel-embed';
|
||||
|
||||
export function CliproxyControlPanelPage() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex flex-col">
|
||||
<ControlPanelEmbed />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+322
-180
@@ -1,159 +1,171 @@
|
||||
/**
|
||||
* CLIProxy Page
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 06: Multi-Account Management
|
||||
* CLIProxy Page - Master-Detail Layout
|
||||
* Left sidebar: Provider navigation + Quick actions
|
||||
* Right panel: Provider Editor with split-view (settings + code editor)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react';
|
||||
import { CliproxyTable } from '@/components/cliproxy-table';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
|
||||
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import {
|
||||
useCliproxy,
|
||||
useCliproxyAuth,
|
||||
useSetDefaultAccount,
|
||||
useRemoveAccount,
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function AccountBadge({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
// Sidebar provider item
|
||||
function ProviderSidebarItem({
|
||||
status,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving: boolean;
|
||||
status: AuthStatus;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const accountCount = status.accounts?.length || 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-full border transition-colors hover:bg-muted/80 ${
|
||||
account.isDefault
|
||||
? 'border-primary/30 bg-primary/10 text-primary font-medium'
|
||||
: 'border-muted bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<User className="w-3 h-3" />
|
||||
<span className="max-w-[200px] truncate">{account.email || account.id}</span>
|
||||
{account.isDefault && <Star className="w-3 h-3 fill-current" />}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/30 -mx-1 -mt-1 mb-1 border-b">
|
||||
<div className="font-medium text-foreground mb-0.5 truncate">
|
||||
{account.email || account.id}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div>Last used: {new Date(account.lastUsedAt).toLocaleDateString()}</div>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer text-left',
|
||||
isSelected
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ProviderLogo provider={status.provider} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{status.displayName}</span>
|
||||
{accountCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">
|
||||
{accountCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/30"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{status.authenticated ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-600">Connected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Not connected</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
status,
|
||||
setDefaultMutation,
|
||||
removeMutation,
|
||||
onAddAccount,
|
||||
// Sidebar variant item (user-created provider variants)
|
||||
function VariantSidebarItem({
|
||||
variant,
|
||||
parentAuth,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
status: AuthStatus;
|
||||
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
|
||||
removeMutation: ReturnType<typeof useRemoveAccount>;
|
||||
onAddAccount: () => void;
|
||||
variant: Variant;
|
||||
parentAuth?: AuthStatus;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
isDeleting?: boolean;
|
||||
}) {
|
||||
const accounts = status.accounts || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b last:border-0 hover:bg-muted/5 transition-colors gap-4">
|
||||
<div className="flex items-center gap-4 min-w-[180px]">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
status.authenticated
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{status.authenticated ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
|
||||
<button
|
||||
className={cn(
|
||||
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
||||
isSelected
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative">
|
||||
<ProviderLogo provider={variant.provider} size="sm" />
|
||||
<GitBranch className="w-2.5 h-2.5 absolute -bottom-0.5 -right-0.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{variant.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
variant
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{status.displayName}
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
|
||||
{accounts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{status.provider}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{parentAuth?.authenticated ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Parent not connected</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex-1 flex items-center gap-2 flex-wrap">
|
||||
{status.authenticated && accounts.length > 0 ? (
|
||||
accounts.map((account) => (
|
||||
<AccountBadge
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSetDefault={() =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
removeMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
isRemoving={removeMutation.isPending}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
{status.authenticated
|
||||
? 'Authenticated (No specific accounts tracked)'
|
||||
: 'Not authenticated'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show Add Account button for all - opens dialog with instructions */}
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Account
|
||||
// Empty state for right panel
|
||||
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center max-w-md px-8">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
|
||||
<Zap className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Manage OAuth authentication, account preferences, and model selection for CLIProxy
|
||||
providers. Configure how CCS routes requests to different AI backends.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-6">
|
||||
For live usage stats and real-time monitoring, visit the{' '}
|
||||
<a href="/cliproxy/control-panel" className="text-primary hover:underline">
|
||||
Control Panel
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button onClick={onSetup} className="gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Quick Setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,86 +173,216 @@ function ProviderRow({
|
||||
}
|
||||
|
||||
export function CliproxyPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||
const { data: variantsData, isFetching } = useCliproxy();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
const deleteMutation = useDeleteVariant();
|
||||
|
||||
// Selection state: either a provider or a variant
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [addAccountProvider, setAddAccountProvider] = useState<{
|
||||
provider: string;
|
||||
displayName: string;
|
||||
} | null>(null);
|
||||
const { data, isLoading } = useCliproxy();
|
||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
|
||||
const providers = authData?.authStatus || [];
|
||||
const variants = variantsData?.variants || [];
|
||||
|
||||
// Auto-select first provider if nothing selected
|
||||
const effectiveProvider = useMemo(() => {
|
||||
// If a variant is selected, no provider is effective
|
||||
if (selectedVariant) return null;
|
||||
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
|
||||
return selectedProvider;
|
||||
}
|
||||
return providers.length > 0 ? providers[0].provider : null;
|
||||
}, [selectedProvider, selectedVariant, providers]);
|
||||
|
||||
const selectedStatus = providers.find((p) => p.provider === effectiveProvider);
|
||||
const selectedVariantData = variants.find((v) => v.name === selectedVariant);
|
||||
const parentAuthForVariant = selectedVariantData
|
||||
? providers.find((p) => p.provider === selectedVariantData.provider)
|
||||
: undefined;
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
};
|
||||
|
||||
const handleSelectProvider = (provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedVariant(null);
|
||||
};
|
||||
|
||||
const handleSelectVariant = (variantName: string) => {
|
||||
setSelectedVariant(variantName);
|
||||
setSelectedProvider(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage OAuth-based provider variants and multi-account configurations
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setWizardOpen(true)}>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Quick Setup
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100vh-100px)] flex">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-64 border-r flex flex-col bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">CLIProxy</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
|
||||
|
||||
{/* Built-in Profiles with Account Management */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Built-in Providers</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage authentication status and accounts for built-in providers.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Quick Setup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="py-0 gap-0">
|
||||
<CardContent className="p-0">
|
||||
{/* Providers List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
||||
Providers
|
||||
</div>
|
||||
{authLoading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
Loading authentication status...
|
||||
<div className="space-y-2 px-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{authData?.authStatus.map((status) => (
|
||||
<ProviderRow
|
||||
<div className="space-y-1">
|
||||
{providers.map((status) => (
|
||||
<ProviderSidebarItem
|
||||
key={status.provider}
|
||||
status={status}
|
||||
setDefaultMutation={setDefaultMutation}
|
||||
removeMutation={removeMutation}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: status.provider,
|
||||
displayName: status.displayName,
|
||||
})
|
||||
}
|
||||
isSelected={effectiveProvider === status.provider}
|
||||
onSelect={() => handleSelectProvider(status.provider)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Custom Variants */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Custom Variants</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create custom aliases for providers with specific accounts.
|
||||
</p>
|
||||
{/* Variants Section */}
|
||||
{variants.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
Variants
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{variants.map((variant) => (
|
||||
<VariantSidebarItem
|
||||
key={variant.name}
|
||||
variant={variant}
|
||||
parentAuth={providers.find((p) => p.provider === variant.provider)}
|
||||
isSelected={selectedVariant === variant.name}
|
||||
onSelect={() => handleSelectVariant(variant.name)}
|
||||
onDelete={() => deleteMutation.mutate(variant.name)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{providers.length} provider{providers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
{providers.filter((p) => p.authenticated).length} connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading variants...</div>
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
/>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<CliproxyTable data={data?.variants || []} />
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||
<AddAccountDialog
|
||||
open={addAccountProvider !== null}
|
||||
|
||||
Reference in New Issue
Block a user