feat: surface saved endpoint models and revamp the admin UI
Browse filesModel ids saved per OpenAI-compatible endpoint on the admin page are now
included in the admin model options and provider status, so numbered
routes (openai_compatible_1/<model>) stay listed even when the endpoint's
live /models route is unreachable.
- settings: OpenAI-compatible instances persist an optional models list
- status: endpoint cards report saved model ids alongside live ones
- admin API: model options merge configured, discovered, and stored ids
- tests: stored endpoint models appear in options/status; config round-trip
Admin UI overhaul:
- dark mode toggle (system-aware, persisted) with a light/dark token pass
- responsive sidebar: off-canvas drawer with backdrop/Escape on mobile
- sticky blurred header (view icon, title, description, version) and a
sticky action bar with status icons and a dirty-count badge
- config sections become cards with per-section icons and a model refresh
action; fields gain source badges and description hints
- provider/connected-account cards gain status dots and per-kind icons
- loading skeletons replace the bare loading text
- new primitives: tooltip, separator, skeleton; tw-animate-css animations
- rebuild admin static assets with content-hashed bundles
- version 4.23.2 -> 4.25.0
- AGENTS.md +10 -0
- CLAUDE.md +10 -0
- README.md +5 -4
- frontend/package-lock.json +71 -1
- frontend/package.json +4 -1
- frontend/src/App.tsx +132 -12
- frontend/src/components/ActionBar.tsx +46 -10
- frontend/src/components/ConfigView.tsx +62 -30
- frontend/src/components/FieldControl.tsx +16 -3
- frontend/src/components/OpenAICompatibleView.tsx +258 -62
- frontend/src/components/ProviderCards.tsx +65 -11
- frontend/src/components/Sidebar.tsx +79 -26
- frontend/src/components/ThemeToggle.tsx +29 -0
- frontend/src/components/theme-provider.tsx +79 -0
- frontend/src/components/ui/separator.tsx +26 -0
- frontend/src/components/ui/skeleton.tsx +13 -0
- frontend/src/components/ui/tooltip.tsx +57 -0
- frontend/src/index.css +17 -0
- frontend/src/lib/status.ts +13 -0
- frontend/src/lib/types.ts +2 -0
- frontend/src/main.tsx +5 -2
- pyproject.toml +1 -1
- src/free_claude_code/api/admin_routes.py +13 -5
- src/free_claude_code/api/admin_static/assets/admin-6Z5kWe4I.css +0 -1
- src/free_claude_code/api/admin_static/assets/admin-Brc4d8H7.css +1 -0
- src/free_claude_code/api/admin_static/assets/admin-D-op9N5Q.js +0 -0
- src/free_claude_code/api/admin_static/assets/admin-GjR0Z_x1.js +0 -0
- src/free_claude_code/api/admin_static/index.html +2 -2
- src/free_claude_code/config/admin/status.py +10 -0
- src/free_claude_code/config/settings.py +5 -0
- tests/api/test_admin.py +43 -1
- tests/config/test_config.py +25 -0
- tests/contracts/test_admin_provider_manifest.py +4 -1
- uv.lock +1 -1
|
@@ -96,6 +96,16 @@ When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new ca
|
|
| 96 |
|
| 97 |
Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
## SUMMARY STANDARDS
|
| 100 |
|
| 101 |
- Summaries must be technical and granular.
|
|
|
|
| 96 |
|
| 97 |
Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
|
| 98 |
|
| 99 |
+
## COMMIT & PUSH POLICY
|
| 100 |
+
|
| 101 |
+
- Never commit or push automatically. Only commit or push when the user
|
| 102 |
+
explicitly asks you to ("commit", "push", "commit and push", or similar).
|
| 103 |
+
- When work is finished and the user has not asked to commit or push, stop
|
| 104 |
+
and ask. Leave the working tree and staged state exactly as they are.
|
| 105 |
+
- Do not stage, amend, or rewrite commits as a side effect of other work.
|
| 106 |
+
- When the user does ask, review the diff first (`git status`, `git diff`)
|
| 107 |
+
and report what will be committed before committing.
|
| 108 |
+
|
| 109 |
## SUMMARY STANDARDS
|
| 110 |
|
| 111 |
- Summaries must be technical and granular.
|
|
@@ -96,6 +96,16 @@ When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new ca
|
|
| 96 |
|
| 97 |
Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
## SUMMARY STANDARDS
|
| 100 |
|
| 101 |
- Summaries must be technical and granular.
|
|
|
|
| 96 |
|
| 97 |
Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
|
| 98 |
|
| 99 |
+
## COMMIT & PUSH POLICY
|
| 100 |
+
|
| 101 |
+
- Never commit or push automatically. Only commit or push when the user
|
| 102 |
+
explicitly asks you to ("commit", "push", "commit and push", or similar).
|
| 103 |
+
- When work is finished and the user has not asked to commit or push, stop
|
| 104 |
+
and ask. Leave the working tree and staged state exactly as they are.
|
| 105 |
+
- Do not stage, amend, or rewrite commits as a side effect of other work.
|
| 106 |
+
- When the user does ask, review the diff first (`git status`, `git diff`)
|
| 107 |
+
and report what will be committed before committing.
|
| 108 |
+
|
| 109 |
## SUMMARY STANDARDS
|
| 110 |
|
| 111 |
- Summaries must be technical and granular.
|
|
@@ -250,10 +250,11 @@ fcc-codex exec "hello"
|
|
| 250 |
on, where the number is the endpoint's position in the list. Give each
|
| 251 |
endpoint its own base URL, optional comma-separated key pool, and optional
|
| 252 |
proxy, then use the numbered routes in `MODEL` so it is easy to see which
|
| 253 |
-
endpoint a request uses.
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
the
|
|
|
|
| 257 |
`openai_compatible/<model-id>` route; the single-endpoint env vars remain
|
| 258 |
the fallback when no endpoints are configured.
|
| 259 |
- Mistral Codestral uses a separate key from Mistral La Plateforme.
|
|
|
|
| 250 |
on, where the number is the endpoint's position in the list. Give each
|
| 251 |
endpoint its own base URL, optional comma-separated key pool, and optional
|
| 252 |
proxy, then use the numbered routes in `MODEL` so it is easy to see which
|
| 253 |
+
endpoint a request uses. Add as many model ids as you want per endpoint
|
| 254 |
+
(comma-separated entries), or **Fetch models** to pull the endpoint's model
|
| 255 |
+
ids from its `/models` route; both list on the card and are selectable in
|
| 256 |
+
the Model Config dropdowns. "Use as default" sets the numbered route as the
|
| 257 |
+
default model. Endpoint 1 also answers the legacy
|
| 258 |
`openai_compatible/<model-id>` route; the single-endpoint env vars remain
|
| 259 |
the fallback when no endpoints are configured.
|
| 260 |
- Mistral Codestral uses a separate key from Mistral La Plateforme.
|
|
@@ -11,13 +11,16 @@
|
|
| 11 |
"@radix-ui/react-checkbox": "^1.1.2",
|
| 12 |
"@radix-ui/react-label": "^2.1.1",
|
| 13 |
"@radix-ui/react-select": "^2.1.2",
|
|
|
|
| 14 |
"@radix-ui/react-slot": "^1.1.0",
|
|
|
|
| 15 |
"class-variance-authority": "^0.7.1",
|
| 16 |
"clsx": "^2.1.1",
|
| 17 |
"lucide-react": "^0.469.0",
|
| 18 |
"react": "^19.0.0",
|
| 19 |
"react-dom": "^19.0.0",
|
| 20 |
-
"tailwind-merge": "^2.6.0"
|
|
|
|
| 21 |
},
|
| 22 |
"devDependencies": {
|
| 23 |
"@tailwindcss/vite": "^4.0.0",
|
|
@@ -1251,6 +1254,29 @@
|
|
| 1251 |
}
|
| 1252 |
}
|
| 1253 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
"node_modules/@radix-ui/react-slot": {
|
| 1255 |
"version": "1.3.3",
|
| 1256 |
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
|
@@ -1269,6 +1295,41 @@
|
|
| 1269 |
}
|
| 1270 |
}
|
| 1271 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
"node_modules/@radix-ui/react-use-callback-ref": {
|
| 1273 |
"version": "1.1.4",
|
| 1274 |
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
|
@@ -3137,6 +3198,15 @@
|
|
| 3137 |
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 3138 |
"license": "0BSD"
|
| 3139 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3140 |
"node_modules/typescript": {
|
| 3141 |
"version": "5.7.3",
|
| 3142 |
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
|
|
|
| 11 |
"@radix-ui/react-checkbox": "^1.1.2",
|
| 12 |
"@radix-ui/react-label": "^2.1.1",
|
| 13 |
"@radix-ui/react-select": "^2.1.2",
|
| 14 |
+
"@radix-ui/react-separator": "^1.1.15",
|
| 15 |
"@radix-ui/react-slot": "^1.1.0",
|
| 16 |
+
"@radix-ui/react-tooltip": "^1.2.16",
|
| 17 |
"class-variance-authority": "^0.7.1",
|
| 18 |
"clsx": "^2.1.1",
|
| 19 |
"lucide-react": "^0.469.0",
|
| 20 |
"react": "^19.0.0",
|
| 21 |
"react-dom": "^19.0.0",
|
| 22 |
+
"tailwind-merge": "^2.6.0",
|
| 23 |
+
"tw-animate-css": "^1.4.0"
|
| 24 |
},
|
| 25 |
"devDependencies": {
|
| 26 |
"@tailwindcss/vite": "^4.0.0",
|
|
|
|
| 1254 |
}
|
| 1255 |
}
|
| 1256 |
},
|
| 1257 |
+
"node_modules/@radix-ui/react-separator": {
|
| 1258 |
+
"version": "1.1.15",
|
| 1259 |
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz",
|
| 1260 |
+
"integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==",
|
| 1261 |
+
"license": "MIT",
|
| 1262 |
+
"dependencies": {
|
| 1263 |
+
"@radix-ui/react-primitive": "2.1.10"
|
| 1264 |
+
},
|
| 1265 |
+
"peerDependencies": {
|
| 1266 |
+
"@types/react": "*",
|
| 1267 |
+
"@types/react-dom": "*",
|
| 1268 |
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
| 1269 |
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
| 1270 |
+
},
|
| 1271 |
+
"peerDependenciesMeta": {
|
| 1272 |
+
"@types/react": {
|
| 1273 |
+
"optional": true
|
| 1274 |
+
},
|
| 1275 |
+
"@types/react-dom": {
|
| 1276 |
+
"optional": true
|
| 1277 |
+
}
|
| 1278 |
+
}
|
| 1279 |
+
},
|
| 1280 |
"node_modules/@radix-ui/react-slot": {
|
| 1281 |
"version": "1.3.3",
|
| 1282 |
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
|
|
|
| 1295 |
}
|
| 1296 |
}
|
| 1297 |
},
|
| 1298 |
+
"node_modules/@radix-ui/react-tooltip": {
|
| 1299 |
+
"version": "1.2.16",
|
| 1300 |
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz",
|
| 1301 |
+
"integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==",
|
| 1302 |
+
"license": "MIT",
|
| 1303 |
+
"dependencies": {
|
| 1304 |
+
"@radix-ui/primitive": "1.1.7",
|
| 1305 |
+
"@radix-ui/react-compose-refs": "1.1.5",
|
| 1306 |
+
"@radix-ui/react-context": "1.2.2",
|
| 1307 |
+
"@radix-ui/react-dismissable-layer": "1.1.19",
|
| 1308 |
+
"@radix-ui/react-id": "1.1.4",
|
| 1309 |
+
"@radix-ui/react-popper": "1.3.7",
|
| 1310 |
+
"@radix-ui/react-portal": "1.1.17",
|
| 1311 |
+
"@radix-ui/react-presence": "1.1.10",
|
| 1312 |
+
"@radix-ui/react-primitive": "2.1.10",
|
| 1313 |
+
"@radix-ui/react-slot": "1.3.3",
|
| 1314 |
+
"@radix-ui/react-use-controllable-state": "1.2.6",
|
| 1315 |
+
"@radix-ui/react-use-layout-effect": "1.1.4",
|
| 1316 |
+
"@radix-ui/react-visually-hidden": "1.2.11"
|
| 1317 |
+
},
|
| 1318 |
+
"peerDependencies": {
|
| 1319 |
+
"@types/react": "*",
|
| 1320 |
+
"@types/react-dom": "*",
|
| 1321 |
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
| 1322 |
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
| 1323 |
+
},
|
| 1324 |
+
"peerDependenciesMeta": {
|
| 1325 |
+
"@types/react": {
|
| 1326 |
+
"optional": true
|
| 1327 |
+
},
|
| 1328 |
+
"@types/react-dom": {
|
| 1329 |
+
"optional": true
|
| 1330 |
+
}
|
| 1331 |
+
}
|
| 1332 |
+
},
|
| 1333 |
"node_modules/@radix-ui/react-use-callback-ref": {
|
| 1334 |
"version": "1.1.4",
|
| 1335 |
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
|
|
|
| 3198 |
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 3199 |
"license": "0BSD"
|
| 3200 |
},
|
| 3201 |
+
"node_modules/tw-animate-css": {
|
| 3202 |
+
"version": "1.4.0",
|
| 3203 |
+
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
| 3204 |
+
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
| 3205 |
+
"license": "MIT",
|
| 3206 |
+
"funding": {
|
| 3207 |
+
"url": "https://github.com/sponsors/Wombosvideo"
|
| 3208 |
+
}
|
| 3209 |
+
},
|
| 3210 |
"node_modules/typescript": {
|
| 3211 |
"version": "5.7.3",
|
| 3212 |
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
|
@@ -13,13 +13,16 @@
|
|
| 13 |
"@radix-ui/react-checkbox": "^1.1.2",
|
| 14 |
"@radix-ui/react-label": "^2.1.1",
|
| 15 |
"@radix-ui/react-select": "^2.1.2",
|
|
|
|
| 16 |
"@radix-ui/react-slot": "^1.1.0",
|
|
|
|
| 17 |
"class-variance-authority": "^0.7.1",
|
| 18 |
"clsx": "^2.1.1",
|
| 19 |
"lucide-react": "^0.469.0",
|
| 20 |
"react": "^19.0.0",
|
| 21 |
"react-dom": "^19.0.0",
|
| 22 |
-
"tailwind-merge": "^2.6.0"
|
|
|
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
| 25 |
"@tailwindcss/vite": "^4.0.0",
|
|
|
|
| 13 |
"@radix-ui/react-checkbox": "^1.1.2",
|
| 14 |
"@radix-ui/react-label": "^2.1.1",
|
| 15 |
"@radix-ui/react-select": "^2.1.2",
|
| 16 |
+
"@radix-ui/react-separator": "^1.1.15",
|
| 17 |
"@radix-ui/react-slot": "^1.1.0",
|
| 18 |
+
"@radix-ui/react-tooltip": "^1.2.16",
|
| 19 |
"class-variance-authority": "^0.7.1",
|
| 20 |
"clsx": "^2.1.1",
|
| 21 |
"lucide-react": "^0.469.0",
|
| 22 |
"react": "^19.0.0",
|
| 23 |
"react-dom": "^19.0.0",
|
| 24 |
+
"tailwind-merge": "^2.6.0",
|
| 25 |
+
"tw-animate-css": "^1.4.0"
|
| 26 |
},
|
| 27 |
"devDependencies": {
|
| 28 |
"@tailwindcss/vite": "^4.0.0",
|
|
@@ -1,4 +1,13 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import {
|
| 4 |
applyConfig,
|
|
@@ -24,6 +33,10 @@ import { ConfigView } from "./components/ConfigView"
|
|
| 24 |
import { OpenAICompatibleView } from "./components/OpenAICompatibleView"
|
| 25 |
import { ProvidersView } from "./components/ProvidersView"
|
| 26 |
import { Sidebar } from "./components/Sidebar"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
export const MASKED_SECRET = "********"
|
| 29 |
|
|
@@ -31,8 +44,10 @@ interface ViewGroup {
|
|
| 31 |
id: string
|
| 32 |
label: string
|
| 33 |
title: string
|
|
|
|
| 34 |
path: string
|
| 35 |
sections: string[]
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
export const VIEW_GROUPS: ViewGroup[] = [
|
|
@@ -40,29 +55,37 @@ export const VIEW_GROUPS: ViewGroup[] = [
|
|
| 40 |
id: "providers",
|
| 41 |
label: "Providers",
|
| 42 |
title: "Providers",
|
|
|
|
| 43 |
path: "providers",
|
| 44 |
sections: ["providers", "runtime"],
|
|
|
|
| 45 |
},
|
| 46 |
{
|
| 47 |
id: "model_config",
|
| 48 |
label: "Model Config",
|
| 49 |
title: "Model Config",
|
|
|
|
| 50 |
path: "model-config",
|
| 51 |
sections: ["models", "reasoning", "web_tools"],
|
|
|
|
| 52 |
},
|
| 53 |
{
|
| 54 |
id: "openai_compatible",
|
| 55 |
label: "OpenAI-Compatible",
|
| 56 |
title: "OpenAI-Compatible Endpoints",
|
|
|
|
| 57 |
path: "openai-compatible",
|
| 58 |
sections: [],
|
|
|
|
| 59 |
},
|
| 60 |
{
|
| 61 |
id: "messaging",
|
| 62 |
label: "Messaging",
|
| 63 |
title: "Messaging",
|
|
|
|
| 64 |
path: "messaging",
|
| 65 |
sections: ["messaging", "voice"],
|
|
|
|
| 66 |
},
|
| 67 |
]
|
| 68 |
|
|
@@ -138,6 +161,7 @@ export default function App() {
|
|
| 138 |
const [message, setMessage] = useState<{ text: string; kind?: string } | null>(null)
|
| 139 |
const [activeView, setActiveView] = useState<string>(() => syncActiveView())
|
| 140 |
const [applying, setApplying] = useState(false)
|
|
|
|
| 141 |
const authTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
| 142 |
|
| 143 |
const setMessageOk = useCallback((text: string) => setMessage({ text }), [])
|
|
@@ -286,8 +310,13 @@ export default function App() {
|
|
| 286 |
void load()
|
| 287 |
const onHashChange = () => setActiveView(viewFromPath(pathFromHash()))
|
| 288 |
window.addEventListener("hashchange", onHashChange)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
return () => {
|
| 290 |
window.removeEventListener("hashchange", onHashChange)
|
|
|
|
| 291 |
for (const timer of Object.values(authTimers.current)) clearTimeout(timer)
|
| 292 |
authTimers.current = {}
|
| 293 |
}
|
|
@@ -412,6 +441,19 @@ export default function App() {
|
|
| 412 |
[setMessageError],
|
| 413 |
)
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
const handleRefreshModels = useCallback(
|
| 416 |
async (_providerId: string, done?: () => void) => {
|
| 417 |
try {
|
|
@@ -501,32 +543,109 @@ export default function App() {
|
|
| 501 |
|
| 502 |
if (!config) {
|
| 503 |
return (
|
| 504 |
-
<div className="flex min-h-screen
|
| 505 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
</div>
|
| 507 |
)
|
| 508 |
}
|
| 509 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
return (
|
| 511 |
<div className="flex min-h-screen">
|
| 512 |
<Sidebar
|
| 513 |
config={config}
|
| 514 |
activeView={activeView}
|
| 515 |
onSelectView={handleSelectView}
|
|
|
|
| 516 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
<main className="flex min-w-0 flex-1 flex-col">
|
| 518 |
-
<header className="flex items-center justify-between border-b px-
|
| 519 |
-
<
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</header>
|
| 528 |
|
| 529 |
-
<div className="flex-1 space-y-8 p-6">
|
| 530 |
{activeView === "providers" && (
|
| 531 |
<ProvidersView
|
| 532 |
config={config}
|
|
@@ -550,6 +669,7 @@ export default function App() {
|
|
| 550 |
localStatus={localStatus}
|
| 551 |
onTestProvider={handleTestProvider}
|
| 552 |
onFetchModels={handleFetchEndpointModels}
|
|
|
|
| 553 |
onMessage={showMessage}
|
| 554 |
/>
|
| 555 |
)}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react"
|
| 2 |
+
import {
|
| 3 |
+
type LucideIcon,
|
| 4 |
+
Menu,
|
| 5 |
+
MessageSquare,
|
| 6 |
+
Plug,
|
| 7 |
+
Server,
|
| 8 |
+
SlidersHorizontal,
|
| 9 |
+
X,
|
| 10 |
+
} from "lucide-react"
|
| 11 |
|
| 12 |
import {
|
| 13 |
applyConfig,
|
|
|
|
| 33 |
import { OpenAICompatibleView } from "./components/OpenAICompatibleView"
|
| 34 |
import { ProvidersView } from "./components/ProvidersView"
|
| 35 |
import { Sidebar } from "./components/Sidebar"
|
| 36 |
+
import { ThemeToggle } from "./components/ThemeToggle"
|
| 37 |
+
import { Badge } from "./components/ui/badge"
|
| 38 |
+
import { Button } from "./components/ui/button"
|
| 39 |
+
import { Skeleton } from "./components/ui/skeleton"
|
| 40 |
|
| 41 |
export const MASKED_SECRET = "********"
|
| 42 |
|
|
|
|
| 44 |
id: string
|
| 45 |
label: string
|
| 46 |
title: string
|
| 47 |
+
description: string
|
| 48 |
path: string
|
| 49 |
sections: string[]
|
| 50 |
+
icon: LucideIcon
|
| 51 |
}
|
| 52 |
|
| 53 |
export const VIEW_GROUPS: ViewGroup[] = [
|
|
|
|
| 55 |
id: "providers",
|
| 56 |
label: "Providers",
|
| 57 |
title: "Providers",
|
| 58 |
+
description: "Check connectivity and manage provider credentials",
|
| 59 |
path: "providers",
|
| 60 |
sections: ["providers", "runtime"],
|
| 61 |
+
icon: Server,
|
| 62 |
},
|
| 63 |
{
|
| 64 |
id: "model_config",
|
| 65 |
label: "Model Config",
|
| 66 |
title: "Model Config",
|
| 67 |
+
description: "Choose models, reasoning behavior, and web tooling",
|
| 68 |
path: "model-config",
|
| 69 |
sections: ["models", "reasoning", "web_tools"],
|
| 70 |
+
icon: SlidersHorizontal,
|
| 71 |
},
|
| 72 |
{
|
| 73 |
id: "openai_compatible",
|
| 74 |
label: "OpenAI-Compatible",
|
| 75 |
title: "OpenAI-Compatible Endpoints",
|
| 76 |
+
description: "Add any OpenAI-compatible server as a numbered provider",
|
| 77 |
path: "openai-compatible",
|
| 78 |
sections: [],
|
| 79 |
+
icon: Plug,
|
| 80 |
},
|
| 81 |
{
|
| 82 |
id: "messaging",
|
| 83 |
label: "Messaging",
|
| 84 |
title: "Messaging",
|
| 85 |
+
description: "Chat platforms and voice integration",
|
| 86 |
path: "messaging",
|
| 87 |
sections: ["messaging", "voice"],
|
| 88 |
+
icon: MessageSquare,
|
| 89 |
},
|
| 90 |
]
|
| 91 |
|
|
|
|
| 161 |
const [message, setMessage] = useState<{ text: string; kind?: string } | null>(null)
|
| 162 |
const [activeView, setActiveView] = useState<string>(() => syncActiveView())
|
| 163 |
const [applying, setApplying] = useState(false)
|
| 164 |
+
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
| 165 |
const authTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
| 166 |
|
| 167 |
const setMessageOk = useCallback((text: string) => setMessage({ text }), [])
|
|
|
|
| 310 |
void load()
|
| 311 |
const onHashChange = () => setActiveView(viewFromPath(pathFromHash()))
|
| 312 |
window.addEventListener("hashchange", onHashChange)
|
| 313 |
+
const onKeyDown = (event: KeyboardEvent) => {
|
| 314 |
+
if (event.key === "Escape") setMobileNavOpen(false)
|
| 315 |
+
}
|
| 316 |
+
window.addEventListener("keydown", onKeyDown)
|
| 317 |
return () => {
|
| 318 |
window.removeEventListener("hashchange", onHashChange)
|
| 319 |
+
window.removeEventListener("keydown", onKeyDown)
|
| 320 |
for (const timer of Object.values(authTimers.current)) clearTimeout(timer)
|
| 321 |
authTimers.current = {}
|
| 322 |
}
|
|
|
|
| 441 |
[setMessageError],
|
| 442 |
)
|
| 443 |
|
| 444 |
+
const handleAddEndpointModels = useCallback(
|
| 445 |
+
(providerId: string, models: string[]) => {
|
| 446 |
+
if (models.length === 0) return
|
| 447 |
+
setModelOptions((prev) => [
|
| 448 |
+
...new Set([
|
| 449 |
+
...prev,
|
| 450 |
+
...models.map((model) => `${providerId}/${model}`),
|
| 451 |
+
]),
|
| 452 |
+
])
|
| 453 |
+
},
|
| 454 |
+
[],
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
const handleRefreshModels = useCallback(
|
| 458 |
async (_providerId: string, done?: () => void) => {
|
| 459 |
try {
|
|
|
|
| 543 |
|
| 544 |
if (!config) {
|
| 545 |
return (
|
| 546 |
+
<div className="flex min-h-screen">
|
| 547 |
+
<aside className="hidden w-64 shrink-0 flex-col gap-3 border-r p-4 md:flex">
|
| 548 |
+
<Skeleton className="h-10 w-full" />
|
| 549 |
+
<div className="mt-3 space-y-2">
|
| 550 |
+
<Skeleton className="h-9 w-5/6" />
|
| 551 |
+
<Skeleton className="h-9 w-4/6" />
|
| 552 |
+
<Skeleton className="h-9 w-3/6" />
|
| 553 |
+
</div>
|
| 554 |
+
</aside>
|
| 555 |
+
<main className="flex min-w-0 flex-1 flex-col gap-6 p-4 md:p-6">
|
| 556 |
+
<div className="space-y-2">
|
| 557 |
+
<Skeleton className="h-7 w-56" />
|
| 558 |
+
<Skeleton className="h-4 w-72" />
|
| 559 |
+
</div>
|
| 560 |
+
<Skeleton className="h-36 w-full" />
|
| 561 |
+
<Skeleton className="h-36 w-full" />
|
| 562 |
+
<Skeleton className="h-36 w-full" />
|
| 563 |
+
</main>
|
| 564 |
</div>
|
| 565 |
)
|
| 566 |
}
|
| 567 |
|
| 568 |
+
const activeGroup =
|
| 569 |
+
VIEW_GROUPS.find((view) => view.id === activeView) ?? VIEW_GROUPS[0]
|
| 570 |
+
const ActiveIcon = activeGroup.icon
|
| 571 |
+
|
| 572 |
return (
|
| 573 |
<div className="flex min-h-screen">
|
| 574 |
<Sidebar
|
| 575 |
config={config}
|
| 576 |
activeView={activeView}
|
| 577 |
onSelectView={handleSelectView}
|
| 578 |
+
className="hidden md:flex"
|
| 579 |
/>
|
| 580 |
+
{mobileNavOpen ? (
|
| 581 |
+
<div
|
| 582 |
+
className="fixed inset-0 z-50 md:hidden"
|
| 583 |
+
role="dialog"
|
| 584 |
+
aria-modal="true"
|
| 585 |
+
aria-label="Admin navigation"
|
| 586 |
+
>
|
| 587 |
+
<button
|
| 588 |
+
type="button"
|
| 589 |
+
aria-label="Close navigation"
|
| 590 |
+
className="absolute inset-0 bg-black/60"
|
| 591 |
+
onClick={() => setMobileNavOpen(false)}
|
| 592 |
+
/>
|
| 593 |
+
<Sidebar
|
| 594 |
+
config={config}
|
| 595 |
+
activeView={activeView}
|
| 596 |
+
onSelectView={handleSelectView}
|
| 597 |
+
onNavigate={() => setMobileNavOpen(false)}
|
| 598 |
+
className="animate-in slide-in-from-left absolute inset-y-0 left-0 shadow-2xl"
|
| 599 |
+
/>
|
| 600 |
+
<button
|
| 601 |
+
type="button"
|
| 602 |
+
aria-label="Close navigation"
|
| 603 |
+
className="absolute top-4 right-4 flex size-8 items-center justify-center rounded-md text-white/80 hover:bg-white/10 hover:text-white"
|
| 604 |
+
onClick={() => setMobileNavOpen(false)}
|
| 605 |
+
>
|
| 606 |
+
<X className="size-5" />
|
| 607 |
+
</button>
|
| 608 |
+
</div>
|
| 609 |
+
) : null}
|
| 610 |
<main className="flex min-w-0 flex-1 flex-col">
|
| 611 |
+
<header className="sticky top-0 z-30 flex items-center justify-between gap-4 border-b bg-background/80 px-4 py-3 backdrop-blur-md md:px-6">
|
| 612 |
+
<div className="flex min-w-0 items-center gap-3">
|
| 613 |
+
<Button
|
| 614 |
+
type="button"
|
| 615 |
+
variant="ghost"
|
| 616 |
+
size="icon"
|
| 617 |
+
className="md:hidden"
|
| 618 |
+
aria-label="Open navigation"
|
| 619 |
+
onClick={() => setMobileNavOpen(true)}
|
| 620 |
+
>
|
| 621 |
+
<Menu />
|
| 622 |
+
</Button>
|
| 623 |
+
<div className="hidden size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary md:flex">
|
| 624 |
+
<ActiveIcon className="size-4.5" />
|
| 625 |
+
</div>
|
| 626 |
+
<div className="min-w-0">
|
| 627 |
+
<h2 className="truncate text-lg font-semibold leading-tight">
|
| 628 |
+
{activeGroup.title}
|
| 629 |
+
</h2>
|
| 630 |
+
<p className="truncate text-xs text-muted-foreground">
|
| 631 |
+
{activeGroup.description}
|
| 632 |
+
</p>
|
| 633 |
+
</div>
|
| 634 |
+
</div>
|
| 635 |
+
<div className="flex shrink-0 items-center gap-1.5">
|
| 636 |
+
{config.version ? (
|
| 637 |
+
<Badge
|
| 638 |
+
variant="outline"
|
| 639 |
+
className="hidden font-mono text-muted-foreground sm:inline-flex"
|
| 640 |
+
>
|
| 641 |
+
v{config.version}
|
| 642 |
+
</Badge>
|
| 643 |
+
) : null}
|
| 644 |
+
<ThemeToggle />
|
| 645 |
+
</div>
|
| 646 |
</header>
|
| 647 |
|
| 648 |
+
<div className="flex-1 space-y-8 p-4 md:p-6">
|
| 649 |
{activeView === "providers" && (
|
| 650 |
<ProvidersView
|
| 651 |
config={config}
|
|
|
|
| 669 |
localStatus={localStatus}
|
| 670 |
onTestProvider={handleTestProvider}
|
| 671 |
onFetchModels={handleFetchEndpointModels}
|
| 672 |
+
onAddModels={handleAddEndpointModels}
|
| 673 |
onMessage={showMessage}
|
| 674 |
/>
|
| 675 |
)}
|
|
@@ -1,4 +1,7 @@
|
|
|
|
|
|
|
|
| 1 |
import { cn } from "@/lib/utils"
|
|
|
|
| 2 |
import { Button } from "./ui/button"
|
| 3 |
|
| 4 |
interface ActionBarProps {
|
|
@@ -10,6 +13,19 @@ interface ActionBarProps {
|
|
| 10 |
onApply: () => void
|
| 11 |
}
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export function ActionBar({
|
| 14 |
configPath,
|
| 15 |
dirtyCount,
|
|
@@ -19,31 +35,51 @@ export function ActionBar({
|
|
| 19 |
onApply,
|
| 20 |
}: ActionBarProps) {
|
| 21 |
return (
|
| 22 |
-
<footer className="flex items-center gap-4 border-t px-
|
| 23 |
-
<div className="flex min-w-0 items-center gap-
|
| 24 |
-
<
|
| 25 |
-
{dirtyCount === 0 ? "
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
<div
|
| 30 |
className={cn(
|
| 31 |
-
"min-w-0 flex-1
|
| 32 |
message?.kind === "error" && "text-destructive",
|
| 33 |
message?.kind === "warn" && "text-amber-600",
|
| 34 |
message?.kind === "ok" && "text-emerald-600",
|
| 35 |
)}
|
|
|
|
|
|
|
| 36 |
>
|
| 37 |
-
{message?.
|
|
|
|
| 38 |
</div>
|
| 39 |
<div className="flex shrink-0 gap-2">
|
| 40 |
-
<Button variant="secondary" onClick={onValidate}>
|
|
|
|
| 41 |
Validate
|
| 42 |
</Button>
|
| 43 |
<Button onClick={onApply} disabled={dirtyCount === 0 || applying}>
|
|
|
|
| 44 |
{applying ? "Applying..." : "Apply"}
|
| 45 |
</Button>
|
| 46 |
</div>
|
| 47 |
</footer>
|
| 48 |
)
|
| 49 |
-
}
|
|
|
|
| 1 |
+
import { CheckCircle2, CircleAlert, Loader2, Play, ShieldCheck, TriangleAlert, XCircle } from "lucide-react"
|
| 2 |
+
|
| 3 |
import { cn } from "@/lib/utils"
|
| 4 |
+
import { Badge } from "./ui/badge"
|
| 5 |
import { Button } from "./ui/button"
|
| 6 |
|
| 7 |
interface ActionBarProps {
|
|
|
|
| 13 |
onApply: () => void
|
| 14 |
}
|
| 15 |
|
| 16 |
+
function messageIcon(kind?: string) {
|
| 17 |
+
switch (kind) {
|
| 18 |
+
case "error":
|
| 19 |
+
return <XCircle className="size-4 shrink-0 text-destructive" />
|
| 20 |
+
case "warn":
|
| 21 |
+
return <TriangleAlert className="size-4 shrink-0 text-amber-600" />
|
| 22 |
+
case "ok":
|
| 23 |
+
return <CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
| 24 |
+
default:
|
| 25 |
+
return null
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
export function ActionBar({
|
| 30 |
configPath,
|
| 31 |
dirtyCount,
|
|
|
|
| 35 |
onApply,
|
| 36 |
}: ActionBarProps) {
|
| 37 |
return (
|
| 38 |
+
<footer className="sticky bottom-0 z-30 flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-background/90 px-4 py-3 backdrop-blur-md md:px-6">
|
| 39 |
+
<div className="flex min-w-0 items-center gap-3">
|
| 40 |
+
<Badge
|
| 41 |
+
variant={dirtyCount === 0 ? "outline" : "warn"}
|
| 42 |
+
className={cn(
|
| 43 |
+
"shrink-0 gap-1.5",
|
| 44 |
+
dirtyCount === 0 && "text-muted-foreground",
|
| 45 |
+
)}
|
| 46 |
+
>
|
| 47 |
+
{dirtyCount === 0 ? (
|
| 48 |
+
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
| 49 |
+
) : (
|
| 50 |
+
<CircleAlert className="size-3.5" />
|
| 51 |
+
)}
|
| 52 |
+
{dirtyCount === 0
|
| 53 |
+
? "No changes"
|
| 54 |
+
: `${dirtyCount} unsaved change${dirtyCount === 1 ? "" : "s"}`}
|
| 55 |
+
</Badge>
|
| 56 |
+
<span className="hidden min-w-0 truncate font-mono text-xs text-muted-foreground lg:inline">
|
| 57 |
+
{configPath}
|
| 58 |
+
</span>
|
| 59 |
</div>
|
| 60 |
<div
|
| 61 |
className={cn(
|
| 62 |
+
"flex min-w-0 flex-1 items-center gap-2 text-sm",
|
| 63 |
message?.kind === "error" && "text-destructive",
|
| 64 |
message?.kind === "warn" && "text-amber-600",
|
| 65 |
message?.kind === "ok" && "text-emerald-600",
|
| 66 |
)}
|
| 67 |
+
role={message?.kind === "error" ? "alert" : undefined}
|
| 68 |
+
aria-live="polite"
|
| 69 |
>
|
| 70 |
+
{messageIcon(message?.kind)}
|
| 71 |
+
<span className="truncate">{message?.text ?? ""}</span>
|
| 72 |
</div>
|
| 73 |
<div className="flex shrink-0 gap-2">
|
| 74 |
+
<Button variant="secondary" onClick={onValidate} disabled={applying}>
|
| 75 |
+
<ShieldCheck />
|
| 76 |
Validate
|
| 77 |
</Button>
|
| 78 |
<Button onClick={onApply} disabled={dirtyCount === 0 || applying}>
|
| 79 |
+
{applying ? <Loader2 className="animate-spin" /> : <Play />}
|
| 80 |
{applying ? "Applying..." : "Apply"}
|
| 81 |
</Button>
|
| 82 |
</div>
|
| 83 |
</footer>
|
| 84 |
)
|
| 85 |
+
}
|
|
@@ -1,9 +1,31 @@
|
|
| 1 |
import { useMemo, useState } from "react"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import { VIEW_GROUPS } from "@/App"
|
| 4 |
import type { AdminConfig, AdminSection } from "@/lib/types"
|
| 5 |
import { FieldRow } from "./FieldControl"
|
| 6 |
import { Button } from "./ui/button"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface ConfigViewProps {
|
| 9 |
config: AdminConfig
|
|
@@ -75,6 +97,7 @@ function SettingsSection({
|
|
| 75 |
const [advancedOpen, setAdvancedOpen] = useState(false)
|
| 76 |
const [refreshing, setRefreshing] = useState(false)
|
| 77 |
const isModelSection = section.id === "models"
|
|
|
|
| 78 |
|
| 79 |
const fieldRows = (showAdvanced: boolean) =>
|
| 80 |
fields
|
|
@@ -91,41 +114,50 @@ function SettingsSection({
|
|
| 91 |
))
|
| 92 |
|
| 93 |
return (
|
| 94 |
-
<
|
| 95 |
-
<
|
| 96 |
-
<div>
|
| 97 |
-
<
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
) : null}
|
| 101 |
</div>
|
| 102 |
-
|
|
|
|
| 103 |
<Button
|
| 104 |
-
|
|
|
|
| 105 |
size="sm"
|
| 106 |
-
|
| 107 |
-
onClick={() =>
|
| 108 |
-
setRefreshing(true)
|
| 109 |
-
onRefreshModels("__refresh_models__", () => setRefreshing(false))
|
| 110 |
-
}}
|
| 111 |
>
|
| 112 |
-
{
|
| 113 |
</Button>
|
| 114 |
) : null}
|
| 115 |
-
</
|
| 116 |
-
|
| 117 |
-
{fieldRows(advancedOpen)}
|
| 118 |
-
</div>
|
| 119 |
-
{hasAdvanced ? (
|
| 120 |
-
<Button
|
| 121 |
-
type="button"
|
| 122 |
-
variant="ghost"
|
| 123 |
-
size="sm"
|
| 124 |
-
onClick={() => setAdvancedOpen((prev) => !prev)}
|
| 125 |
-
>
|
| 126 |
-
{advancedOpen ? "Hide advanced" : "Show advanced"}
|
| 127 |
-
</Button>
|
| 128 |
-
) : null}
|
| 129 |
-
</section>
|
| 130 |
)
|
| 131 |
-
}
|
|
|
|
| 1 |
import { useMemo, useState } from "react"
|
| 2 |
+
import {
|
| 3 |
+
Brain,
|
| 4 |
+
Gauge,
|
| 5 |
+
Globe,
|
| 6 |
+
Lightbulb,
|
| 7 |
+
MessageSquare,
|
| 8 |
+
Mic,
|
| 9 |
+
RefreshCw,
|
| 10 |
+
Server,
|
| 11 |
+
type LucideIcon,
|
| 12 |
+
} from "lucide-react"
|
| 13 |
|
| 14 |
import { VIEW_GROUPS } from "@/App"
|
| 15 |
import type { AdminConfig, AdminSection } from "@/lib/types"
|
| 16 |
import { FieldRow } from "./FieldControl"
|
| 17 |
import { Button } from "./ui/button"
|
| 18 |
+
import { Card, CardContent } from "./ui/card"
|
| 19 |
+
|
| 20 |
+
const SECTION_ICONS: Record<string, LucideIcon> = {
|
| 21 |
+
providers: Server,
|
| 22 |
+
runtime: Gauge,
|
| 23 |
+
models: Brain,
|
| 24 |
+
reasoning: Lightbulb,
|
| 25 |
+
web_tools: Globe,
|
| 26 |
+
messaging: MessageSquare,
|
| 27 |
+
voice: Mic,
|
| 28 |
+
}
|
| 29 |
|
| 30 |
interface ConfigViewProps {
|
| 31 |
config: AdminConfig
|
|
|
|
| 97 |
const [advancedOpen, setAdvancedOpen] = useState(false)
|
| 98 |
const [refreshing, setRefreshing] = useState(false)
|
| 99 |
const isModelSection = section.id === "models"
|
| 100 |
+
const SectionIcon = SECTION_ICONS[section.id] ?? Server
|
| 101 |
|
| 102 |
const fieldRows = (showAdvanced: boolean) =>
|
| 103 |
fields
|
|
|
|
| 114 |
))
|
| 115 |
|
| 116 |
return (
|
| 117 |
+
<Card className="gap-0 overflow-hidden">
|
| 118 |
+
<CardContent className="flex flex-col gap-4 px-5 py-4 md:px-6">
|
| 119 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 120 |
+
<div className="flex min-w-0 items-center gap-3">
|
| 121 |
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
| 122 |
+
<SectionIcon className="size-4.5" />
|
| 123 |
+
</span>
|
| 124 |
+
<div className="min-w-0">
|
| 125 |
+
<h3 className="text-base font-semibold leading-tight">
|
| 126 |
+
{section.label}
|
| 127 |
+
</h3>
|
| 128 |
+
{section.description ? (
|
| 129 |
+
<p className="text-sm text-muted-foreground">{section.description}</p>
|
| 130 |
+
) : null}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
{isModelSection ? (
|
| 134 |
+
<Button
|
| 135 |
+
variant="secondary"
|
| 136 |
+
size="sm"
|
| 137 |
+
disabled={refreshing}
|
| 138 |
+
onClick={() => {
|
| 139 |
+
setRefreshing(true)
|
| 140 |
+
onRefreshModels("__refresh_models__", () => setRefreshing(false))
|
| 141 |
+
}}
|
| 142 |
+
>
|
| 143 |
+
<RefreshCw className={refreshing ? "animate-spin" : undefined} />
|
| 144 |
+
{refreshing ? "Refreshing" : "Refresh models"}
|
| 145 |
+
</Button>
|
| 146 |
) : null}
|
| 147 |
</div>
|
| 148 |
+
<div className="grid gap-3">{fieldRows(advancedOpen)}</div>
|
| 149 |
+
{hasAdvanced ? (
|
| 150 |
<Button
|
| 151 |
+
type="button"
|
| 152 |
+
variant="ghost"
|
| 153 |
size="sm"
|
| 154 |
+
className="self-start text-muted-foreground"
|
| 155 |
+
onClick={() => setAdvancedOpen((prev) => !prev)}
|
|
|
|
|
|
|
|
|
|
| 156 |
>
|
| 157 |
+
{advancedOpen ? "Hide advanced" : "Show advanced"}
|
| 158 |
</Button>
|
| 159 |
) : null}
|
| 160 |
+
</CardContent>
|
| 161 |
+
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
)
|
| 163 |
+
}
|
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import { useState } from "react"
|
|
|
|
| 2 |
|
| 3 |
import type { AdminField } from "@/lib/types"
|
| 4 |
import { cn } from "@/lib/utils"
|
|
|
|
| 5 |
import { Checkbox } from "./ui/checkbox"
|
| 6 |
import { Input } from "./ui/input"
|
| 7 |
import { Label } from "./ui/label"
|
|
@@ -162,10 +164,11 @@ export function FieldRow({
|
|
| 162 |
onValueChange,
|
| 163 |
onMessage,
|
| 164 |
}: FieldRowProps) {
|
|
|
|
| 165 |
return (
|
| 166 |
<div
|
| 167 |
className={cn(
|
| 168 |
-
"flex flex-col gap-2 rounded-lg border p-4",
|
| 169 |
field.advanced && "advanced-field hidden",
|
| 170 |
)}
|
| 171 |
data-key={field.key}
|
|
@@ -174,7 +177,14 @@ export function FieldRow({
|
|
| 174 |
<Label htmlFor={field.type === "boolean" ? undefined : `field-${field.key}`}>
|
| 175 |
<span className="text-sm font-medium">{field.label}</span>
|
| 176 |
</Label>
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
<FieldControl
|
| 180 |
field={field}
|
|
@@ -184,7 +194,10 @@ export function FieldRow({
|
|
| 184 |
onMessage={onMessage}
|
| 185 |
/>
|
| 186 |
{field.description ? (
|
| 187 |
-
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
|
|
|
|
| 188 |
) : null}
|
| 189 |
</div>
|
| 190 |
)
|
|
|
|
| 1 |
import { useState } from "react"
|
| 2 |
+
import { Info } from "lucide-react"
|
| 3 |
|
| 4 |
import type { AdminField } from "@/lib/types"
|
| 5 |
import { cn } from "@/lib/utils"
|
| 6 |
+
import { Badge } from "./ui/badge"
|
| 7 |
import { Checkbox } from "./ui/checkbox"
|
| 8 |
import { Input } from "./ui/input"
|
| 9 |
import { Label } from "./ui/label"
|
|
|
|
| 164 |
onValueChange,
|
| 165 |
onMessage,
|
| 166 |
}: FieldRowProps) {
|
| 167 |
+
const source = sourceText(field)
|
| 168 |
return (
|
| 169 |
<div
|
| 170 |
className={cn(
|
| 171 |
+
"flex flex-col gap-2.5 rounded-lg border bg-card/40 p-4 transition-colors hover:border-border/80",
|
| 172 |
field.advanced && "advanced-field hidden",
|
| 173 |
)}
|
| 174 |
data-key={field.key}
|
|
|
|
| 177 |
<Label htmlFor={field.type === "boolean" ? undefined : `field-${field.key}`}>
|
| 178 |
<span className="text-sm font-medium">{field.label}</span>
|
| 179 |
</Label>
|
| 180 |
+
{source ? (
|
| 181 |
+
<Badge
|
| 182 |
+
variant="outline"
|
| 183 |
+
className="gap-1 px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
|
| 184 |
+
>
|
| 185 |
+
{source}
|
| 186 |
+
</Badge>
|
| 187 |
+
) : null}
|
| 188 |
</div>
|
| 189 |
<FieldControl
|
| 190 |
field={field}
|
|
|
|
| 194 |
onMessage={onMessage}
|
| 195 |
/>
|
| 196 |
{field.description ? (
|
| 197 |
+
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
| 198 |
+
<Info className="mt-0.5 size-3.5 shrink-0 opacity-70" />
|
| 199 |
+
<span>{field.description}</span>
|
| 200 |
+
</p>
|
| 201 |
) : null}
|
| 202 |
</div>
|
| 203 |
)
|
|
@@ -1,13 +1,29 @@
|
|
| 1 |
import { useEffect, useState } from "react"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
|
|
|
| 3 |
import type {
|
| 4 |
AdminConfig,
|
| 5 |
LocalProviderStatus,
|
| 6 |
OpenAICompatibleInstance,
|
| 7 |
} from "@/lib/types"
|
|
|
|
|
|
|
| 8 |
import { Badge } from "./ui/badge"
|
| 9 |
import { Button } from "./ui/button"
|
| 10 |
import { Card, CardContent } from "./ui/card"
|
|
|
|
| 11 |
import { Input } from "./ui/input"
|
| 12 |
import { Label } from "./ui/label"
|
| 13 |
|
|
@@ -20,6 +36,7 @@ interface OpenAICompatibleViewProps {
|
|
| 20 |
localStatus: Record<string, LocalProviderStatus>
|
| 21 |
onTestProvider: (providerId: string, done?: () => void) => void
|
| 22 |
onFetchModels: (providerId: string) => Promise<string[]>
|
|
|
|
| 23 |
onMessage: (text: string, kind?: string) => void
|
| 24 |
}
|
| 25 |
|
|
@@ -70,14 +87,6 @@ function statusFor(
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
-
function badgeVariant(status: string): "ok" | "warn" | "error" | "neutral" {
|
| 74 |
-
if (["configured", "reachable"].includes(status)) return "ok"
|
| 75 |
-
if (["missing_key", "missing_config", "missing_url", "unknown"].includes(status))
|
| 76 |
-
return "warn"
|
| 77 |
-
if (["offline", "error"].includes(status)) return "error"
|
| 78 |
-
return "neutral"
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
interface InstanceCardProps {
|
| 82 |
index: number
|
| 83 |
instance: OpenAICompatibleInstance
|
|
@@ -86,11 +95,15 @@ interface InstanceCardProps {
|
|
| 86 |
testing: boolean
|
| 87 |
fetching: boolean
|
| 88 |
models: string[]
|
|
|
|
|
|
|
| 89 |
onPatch: (patch: Partial<OpenAICompatibleInstance>) => void
|
| 90 |
onRemove: () => void
|
| 91 |
onTest: () => void
|
| 92 |
onFetch: () => void
|
| 93 |
onClearModels: () => void
|
|
|
|
|
|
|
| 94 |
onUseModel: (model: string) => void
|
| 95 |
}
|
| 96 |
|
|
@@ -102,35 +115,63 @@ function InstanceCard({
|
|
| 102 |
testing,
|
| 103 |
fetching,
|
| 104 |
models,
|
|
|
|
|
|
|
| 105 |
onPatch,
|
| 106 |
onRemove,
|
| 107 |
onTest,
|
| 108 |
onFetch,
|
| 109 |
onClearModels,
|
|
|
|
|
|
|
| 110 |
onUseModel,
|
| 111 |
}: InstanceCardProps) {
|
| 112 |
const providerId = providerIdFor(index)
|
| 113 |
const canProbe = !locked && Boolean(instance.base_url.trim())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
return (
|
| 115 |
-
<Card>
|
| 116 |
<CardContent className="flex flex-col gap-4 py-4">
|
| 117 |
<div className="flex flex-wrap items-center justify-between gap-2">
|
| 118 |
-
<div className="flex items-center gap-
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</div>
|
| 124 |
-
<div className="flex items-center gap-
|
| 125 |
-
<Badge variant={
|
|
|
|
|
|
|
|
|
|
| 126 |
<Button
|
| 127 |
type="button"
|
| 128 |
variant="ghost"
|
| 129 |
-
size="
|
| 130 |
disabled={locked}
|
|
|
|
|
|
|
| 131 |
onClick={onRemove}
|
| 132 |
>
|
| 133 |
-
|
| 134 |
</Button>
|
| 135 |
</div>
|
| 136 |
</div>
|
|
@@ -174,12 +215,23 @@ function InstanceCard({
|
|
| 174 |
onChange={(event) => onPatch({ proxy: event.target.value })}
|
| 175 |
/>
|
| 176 |
</div>
|
| 177 |
-
|
| 178 |
-
<div className="flex
|
| 179 |
-
<div className="flex items-center
|
| 180 |
-
<p className="text-sm font-medium">
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
<Button
|
| 184 |
type="button"
|
| 185 |
variant="ghost"
|
|
@@ -187,34 +239,99 @@ function InstanceCard({
|
|
| 187 |
disabled={locked}
|
| 188 |
onClick={onClearModels}
|
| 189 |
>
|
|
|
|
| 190 |
Clear
|
| 191 |
</Button>
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
<div className="grid max-h-56 gap-1 overflow-auto">
|
| 194 |
-
{models.map((model) =>
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
size="sm"
|
| 206 |
-
disabled={locked}
|
| 207 |
-
onClick={() => onUseModel(model)}
|
| 208 |
>
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
</div>
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
{status.baseUrl ? `Resolves to ${status.baseUrl}` : "Not checked yet"}
|
| 219 |
</p>
|
| 220 |
<div className="flex items-center gap-2">
|
|
@@ -226,6 +343,11 @@ function InstanceCard({
|
|
| 226 |
onClick={onFetch}
|
| 227 |
title="Fetch model ids from GET {base}/models (uses the saved config, Apply first)"
|
| 228 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
{fetching ? "Fetching…" : "Fetch models"}
|
| 230 |
</Button>
|
| 231 |
<Button
|
|
@@ -235,6 +357,7 @@ function InstanceCard({
|
|
| 235 |
disabled={!canProbe || testing}
|
| 236 |
onClick={onTest}
|
| 237 |
>
|
|
|
|
| 238 |
{testing ? "Testing…" : "Test connection"}
|
| 239 |
</Button>
|
| 240 |
</div>
|
|
@@ -251,6 +374,7 @@ export function OpenAICompatibleView({
|
|
| 251 |
localStatus,
|
| 252 |
onTestProvider,
|
| 253 |
onFetchModels,
|
|
|
|
| 254 |
onMessage,
|
| 255 |
}: OpenAICompatibleViewProps) {
|
| 256 |
const field = config.fields.find(
|
|
@@ -297,19 +421,52 @@ export function OpenAICompatibleView({
|
|
| 297 |
try {
|
| 298 |
const models = await onFetchModels(providerId)
|
| 299 |
if (models.length === 0) {
|
| 300 |
-
|
| 301 |
return
|
| 302 |
}
|
| 303 |
-
setModelsByProvider((prev) => ({
|
|
|
|
|
|
|
|
|
|
| 304 |
onMessage(`Fetched ${models.length} models from ${providerId}.`, "ok")
|
| 305 |
} finally {
|
| 306 |
setFetchingId(null)
|
| 307 |
}
|
| 308 |
}
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
const useModelAsDefault = (index: number, model: string) => {
|
| 311 |
const route = `${providerIdFor(index)}/${model}`
|
| 312 |
onValuesChange({ ...values, MODEL: route })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
onMessage(`Default model set to ${route}. Apply to save.`, "ok")
|
| 314 |
}
|
| 315 |
|
|
@@ -323,20 +480,45 @@ export function OpenAICompatibleView({
|
|
| 323 |
|
| 324 |
const legacyBaseUrl = values["OPENAI_COMPATIBLE_BASE_URL"] ?? ""
|
| 325 |
const legacyKey = values["OPENAI_COMPATIBLE_API_KEY"] ?? ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
return (
|
| 328 |
<section className="space-y-4">
|
| 329 |
<div>
|
| 330 |
<h3 className="text-lg font-semibold">Endpoints</h3>
|
| 331 |
-
<p className="text-sm text-muted-foreground">
|
| 332 |
Add any OpenAI-compatible server (vLLM, LM Studio, Ollama, Together, or
|
| 333 |
your own gateway). Each endpoint becomes a numbered provider:{" "}
|
| 334 |
<code>openai_compatible_1/<model></code>,{" "}
|
| 335 |
-
<code>openai_compatible_2/<model></code>, …
|
| 336 |
-
|
| 337 |
-
<
|
| 338 |
-
|
|
|
|
|
|
|
| 339 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</div>
|
| 341 |
|
| 342 |
{instances.length === 0 && legacyBaseUrl.trim() ? (
|
|
@@ -354,6 +536,7 @@ export function OpenAICompatibleView({
|
|
| 354 |
commit([{ base_url: legacyBaseUrl, api_keys: legacyKey, proxy: "" }])
|
| 355 |
}
|
| 356 |
>
|
|
|
|
| 357 |
Import as endpoint 1
|
| 358 |
</Button>
|
| 359 |
</div>
|
|
@@ -361,11 +544,15 @@ export function OpenAICompatibleView({
|
|
| 361 |
|
| 362 |
{instances.length === 0 ? (
|
| 363 |
<Card className="border-dashed">
|
| 364 |
-
<CardContent className="flex flex-col items-center gap-3 py-
|
|
|
|
|
|
|
|
|
|
| 365 |
<p className="text-sm text-muted-foreground">
|
| 366 |
No endpoints yet. Add your first OpenAI-compatible endpoint.
|
| 367 |
</p>
|
| 368 |
<Button type="button" onClick={addInstance}>
|
|
|
|
| 369 |
Add endpoint
|
| 370 |
</Button>
|
| 371 |
</CardContent>
|
|
@@ -383,7 +570,9 @@ export function OpenAICompatibleView({
|
|
| 383 |
locked={field.locked}
|
| 384 |
testing={testingId === providerId}
|
| 385 |
fetching={fetchingId === providerId}
|
| 386 |
-
models={
|
|
|
|
|
|
|
| 387 |
onPatch={(patch) => updateInstance(index, patch)}
|
| 388 |
onRemove={() => removeInstance(index)}
|
| 389 |
onTest={() => {
|
|
@@ -391,18 +580,25 @@ export function OpenAICompatibleView({
|
|
| 391 |
onTestProvider(providerId, () => setTestingId(null))
|
| 392 |
}}
|
| 393 |
onFetch={() => void fetchModels(index)}
|
| 394 |
-
onClearModels={() =>
|
| 395 |
setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
onUseModel={(model) => useModelAsDefault(index, model)}
|
| 398 |
/>
|
| 399 |
)
|
| 400 |
})}
|
| 401 |
-
<
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
</div>
|
| 407 |
)}
|
| 408 |
</section>
|
|
|
|
| 1 |
import { useEffect, useState } from "react"
|
| 2 |
+
import {
|
| 3 |
+
Cable,
|
| 4 |
+
Check,
|
| 5 |
+
ListChecks,
|
| 6 |
+
ListPlus,
|
| 7 |
+
Loader2,
|
| 8 |
+
Plus,
|
| 9 |
+
RefreshCw,
|
| 10 |
+
Server,
|
| 11 |
+
Star,
|
| 12 |
+
Trash2,
|
| 13 |
+
} from "lucide-react"
|
| 14 |
|
| 15 |
+
import { statusClass } from "@/App"
|
| 16 |
import type {
|
| 17 |
AdminConfig,
|
| 18 |
LocalProviderStatus,
|
| 19 |
OpenAICompatibleInstance,
|
| 20 |
} from "@/lib/types"
|
| 21 |
+
import { statusDotClass } from "@/lib/status"
|
| 22 |
+
import { cn } from "@/lib/utils"
|
| 23 |
import { Badge } from "./ui/badge"
|
| 24 |
import { Button } from "./ui/button"
|
| 25 |
import { Card, CardContent } from "./ui/card"
|
| 26 |
+
import { Checkbox } from "./ui/checkbox"
|
| 27 |
import { Input } from "./ui/input"
|
| 28 |
import { Label } from "./ui/label"
|
| 29 |
|
|
|
|
| 36 |
localStatus: Record<string, LocalProviderStatus>
|
| 37 |
onTestProvider: (providerId: string, done?: () => void) => void
|
| 38 |
onFetchModels: (providerId: string) => Promise<string[]>
|
| 39 |
+
onAddModels: (providerId: string, models: string[]) => void
|
| 40 |
onMessage: (text: string, kind?: string) => void
|
| 41 |
}
|
| 42 |
|
|
|
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
interface InstanceCardProps {
|
| 91 |
index: number
|
| 92 |
instance: OpenAICompatibleInstance
|
|
|
|
| 95 |
testing: boolean
|
| 96 |
fetching: boolean
|
| 97 |
models: string[]
|
| 98 |
+
selected: string[]
|
| 99 |
+
currentDefault: string
|
| 100 |
onPatch: (patch: Partial<OpenAICompatibleInstance>) => void
|
| 101 |
onRemove: () => void
|
| 102 |
onTest: () => void
|
| 103 |
onFetch: () => void
|
| 104 |
onClearModels: () => void
|
| 105 |
+
onAddModels: (models: string[]) => void
|
| 106 |
+
onToggleModel: (model: string) => void
|
| 107 |
onUseModel: (model: string) => void
|
| 108 |
}
|
| 109 |
|
|
|
|
| 115 |
testing,
|
| 116 |
fetching,
|
| 117 |
models,
|
| 118 |
+
selected,
|
| 119 |
+
currentDefault,
|
| 120 |
onPatch,
|
| 121 |
onRemove,
|
| 122 |
onTest,
|
| 123 |
onFetch,
|
| 124 |
onClearModels,
|
| 125 |
+
onAddModels,
|
| 126 |
+
onToggleModel,
|
| 127 |
onUseModel,
|
| 128 |
}: InstanceCardProps) {
|
| 129 |
const providerId = providerIdFor(index)
|
| 130 |
const canProbe = !locked && Boolean(instance.base_url.trim())
|
| 131 |
+
const [modelDraft, setModelDraft] = useState("")
|
| 132 |
+
|
| 133 |
+
const addFromDraft = () => {
|
| 134 |
+
const ids = modelDraft
|
| 135 |
+
.split(",")
|
| 136 |
+
.map((id) => id.trim())
|
| 137 |
+
.filter(Boolean)
|
| 138 |
+
if (ids.length === 0) return
|
| 139 |
+
onAddModels(ids)
|
| 140 |
+
setModelDraft("")
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
return (
|
| 144 |
+
<Card className="overflow-hidden">
|
| 145 |
<CardContent className="flex flex-col gap-4 py-4">
|
| 146 |
<div className="flex flex-wrap items-center justify-between gap-2">
|
| 147 |
+
<div className="flex items-center gap-3">
|
| 148 |
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary ring-1 ring-primary/20">
|
| 149 |
+
{index + 1}
|
| 150 |
+
</span>
|
| 151 |
+
<div className="min-w-0">
|
| 152 |
+
<h4 className="text-base font-semibold leading-tight">
|
| 153 |
+
Endpoint {index + 1}
|
| 154 |
+
</h4>
|
| 155 |
+
<p className="font-mono text-xs text-muted-foreground">
|
| 156 |
+
{providerId}/<model>
|
| 157 |
+
</p>
|
| 158 |
+
</div>
|
| 159 |
</div>
|
| 160 |
+
<div className="flex items-center gap-1.5">
|
| 161 |
+
<Badge variant={statusClass(status.status)} className="gap-1.5">
|
| 162 |
+
<span className={cn("size-1.5 rounded-full", statusDotClass(status.status))} />
|
| 163 |
+
{status.label}
|
| 164 |
+
</Badge>
|
| 165 |
<Button
|
| 166 |
type="button"
|
| 167 |
variant="ghost"
|
| 168 |
+
size="icon"
|
| 169 |
disabled={locked}
|
| 170 |
+
aria-label={`Remove endpoint ${index + 1}`}
|
| 171 |
+
title="Remove endpoint"
|
| 172 |
onClick={onRemove}
|
| 173 |
>
|
| 174 |
+
<Trash2 />
|
| 175 |
</Button>
|
| 176 |
</div>
|
| 177 |
</div>
|
|
|
|
| 215 |
onChange={(event) => onPatch({ proxy: event.target.value })}
|
| 216 |
/>
|
| 217 |
</div>
|
| 218 |
+
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
|
| 219 |
+
<div className="flex items-center justify-between gap-2">
|
| 220 |
+
<div className="flex items-center gap-2">
|
| 221 |
+
<p className="flex items-center gap-1.5 text-sm font-medium">
|
| 222 |
+
<ListChecks className="size-4 text-muted-foreground" />
|
| 223 |
+
{models.length === 0
|
| 224 |
+
? "No model ids yet"
|
| 225 |
+
: `${models.length} model id${models.length === 1 ? "" : "s"}`}
|
| 226 |
</p>
|
| 227 |
+
{selected.length > 0 ? (
|
| 228 |
+
<Badge variant="outline" className="gap-1 px-1.5 py-0 text-[10px]">
|
| 229 |
+
<Check className="size-3" />
|
| 230 |
+
{selected.length} selected
|
| 231 |
+
</Badge>
|
| 232 |
+
) : null}
|
| 233 |
+
</div>
|
| 234 |
+
{models.length > 0 ? (
|
| 235 |
<Button
|
| 236 |
type="button"
|
| 237 |
variant="ghost"
|
|
|
|
| 239 |
disabled={locked}
|
| 240 |
onClick={onClearModels}
|
| 241 |
>
|
| 242 |
+
<Trash2 />
|
| 243 |
Clear
|
| 244 |
</Button>
|
| 245 |
+
) : null}
|
| 246 |
+
</div>
|
| 247 |
+
<div className="flex gap-2">
|
| 248 |
+
<Input
|
| 249 |
+
id={`${providerId}-model-id`}
|
| 250 |
+
type="text"
|
| 251 |
+
autoComplete="off"
|
| 252 |
+
placeholder="Add model id, comma-separated (gpt-4o, deepseek-v3, …)"
|
| 253 |
+
value={modelDraft}
|
| 254 |
+
disabled={locked}
|
| 255 |
+
onChange={(event) => setModelDraft(event.target.value)}
|
| 256 |
+
onKeyDown={(event) => {
|
| 257 |
+
if (event.key === "Enter") {
|
| 258 |
+
event.preventDefault()
|
| 259 |
+
addFromDraft()
|
| 260 |
+
}
|
| 261 |
+
}}
|
| 262 |
+
/>
|
| 263 |
+
<Button
|
| 264 |
+
type="button"
|
| 265 |
+
variant="secondary"
|
| 266 |
+
size="sm"
|
| 267 |
+
disabled={locked || !modelDraft.trim()}
|
| 268 |
+
onClick={addFromDraft}
|
| 269 |
+
>
|
| 270 |
+
<ListPlus />
|
| 271 |
+
Add
|
| 272 |
+
</Button>
|
| 273 |
+
</div>
|
| 274 |
+
{models.length > 0 ? (
|
| 275 |
<div className="grid max-h-56 gap-1 overflow-auto">
|
| 276 |
+
{models.map((model) => {
|
| 277 |
+
const isDefault = `${providerId}/${model}` === currentDefault
|
| 278 |
+
return (
|
| 279 |
+
<div
|
| 280 |
+
key={model}
|
| 281 |
+
className={cn(
|
| 282 |
+
"flex items-center gap-2 rounded-md border px-2.5 py-1.5 transition-colors hover:border-border",
|
| 283 |
+
isDefault
|
| 284 |
+
? "border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10"
|
| 285 |
+
: "border-transparent bg-card",
|
| 286 |
+
)}
|
|
|
|
|
|
|
|
|
|
| 287 |
>
|
| 288 |
+
<Checkbox
|
| 289 |
+
id={`${providerId}-model-${model}`}
|
| 290 |
+
checked={selected.includes(model)}
|
| 291 |
+
disabled={locked}
|
| 292 |
+
aria-label={`Apply model ${model}`}
|
| 293 |
+
title={
|
| 294 |
+
selected.includes(model)
|
| 295 |
+
? "Applied to this endpoint"
|
| 296 |
+
: "Select to apply this model"
|
| 297 |
+
}
|
| 298 |
+
onCheckedChange={() => onToggleModel(model)}
|
| 299 |
+
/>
|
| 300 |
+
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
| 301 |
+
{isDefault ? (
|
| 302 |
+
<Star className="size-3.5 shrink-0 fill-amber-400 text-amber-500" />
|
| 303 |
+
) : null}
|
| 304 |
+
<span className="truncate font-mono text-xs">{model}</span>
|
| 305 |
+
{isDefault ? (
|
| 306 |
+
<Badge variant="ok" className="px-1.5 py-0 text-[10px]">
|
| 307 |
+
default
|
| 308 |
+
</Badge>
|
| 309 |
+
) : null}
|
| 310 |
+
</span>
|
| 311 |
+
<Button
|
| 312 |
+
type="button"
|
| 313 |
+
variant="ghost"
|
| 314 |
+
size="sm"
|
| 315 |
+
disabled={locked}
|
| 316 |
+
onClick={() => onUseModel(model)}
|
| 317 |
+
>
|
| 318 |
+
<Star className="size-3.5" />
|
| 319 |
+
Use as default
|
| 320 |
+
</Button>
|
| 321 |
+
</div>
|
| 322 |
+
)
|
| 323 |
+
})}
|
| 324 |
</div>
|
| 325 |
+
) : (
|
| 326 |
+
<p className="text-xs text-muted-foreground">
|
| 327 |
+
Add model ids manually, or use <strong>Fetch models</strong> below
|
| 328 |
+
to pull them from GET {`{base}`}/models.
|
| 329 |
+
</p>
|
| 330 |
+
)}
|
| 331 |
+
</div>
|
| 332 |
+
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
| 333 |
+
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
| 334 |
+
<Cable className="size-3.5" />
|
| 335 |
{status.baseUrl ? `Resolves to ${status.baseUrl}` : "Not checked yet"}
|
| 336 |
</p>
|
| 337 |
<div className="flex items-center gap-2">
|
|
|
|
| 343 |
onClick={onFetch}
|
| 344 |
title="Fetch model ids from GET {base}/models (uses the saved config, Apply first)"
|
| 345 |
>
|
| 346 |
+
{fetching ? (
|
| 347 |
+
<Loader2 className="animate-spin" />
|
| 348 |
+
) : (
|
| 349 |
+
<RefreshCw />
|
| 350 |
+
)}
|
| 351 |
{fetching ? "Fetching…" : "Fetch models"}
|
| 352 |
</Button>
|
| 353 |
<Button
|
|
|
|
| 357 |
disabled={!canProbe || testing}
|
| 358 |
onClick={onTest}
|
| 359 |
>
|
| 360 |
+
{testing ? <Loader2 className="animate-spin" /> : <Cable />}
|
| 361 |
{testing ? "Testing…" : "Test connection"}
|
| 362 |
</Button>
|
| 363 |
</div>
|
|
|
|
| 374 |
localStatus,
|
| 375 |
onTestProvider,
|
| 376 |
onFetchModels,
|
| 377 |
+
onAddModels,
|
| 378 |
onMessage,
|
| 379 |
}: OpenAICompatibleViewProps) {
|
| 380 |
const field = config.fields.find(
|
|
|
|
| 421 |
try {
|
| 422 |
const models = await onFetchModels(providerId)
|
| 423 |
if (models.length === 0) {
|
| 424 |
+
onMessage(`No models found at ${providerId}.`, "warn")
|
| 425 |
return
|
| 426 |
}
|
| 427 |
+
setModelsByProvider((prev) => ({
|
| 428 |
+
...prev,
|
| 429 |
+
[providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
|
| 430 |
+
}))
|
| 431 |
onMessage(`Fetched ${models.length} models from ${providerId}.`, "ok")
|
| 432 |
} finally {
|
| 433 |
setFetchingId(null)
|
| 434 |
}
|
| 435 |
}
|
| 436 |
|
| 437 |
+
const addModels = (index: number, models: string[]) => {
|
| 438 |
+
const providerId = providerIdFor(index)
|
| 439 |
+
setModelsByProvider((prev) => ({
|
| 440 |
+
...prev,
|
| 441 |
+
[providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
|
| 442 |
+
}))
|
| 443 |
+
// Manually added ids are deliberately typed, so apply them immediately.
|
| 444 |
+
const current = instances[index]?.models ?? []
|
| 445 |
+
updateInstance(index, { models: [...new Set([...current, ...models])] })
|
| 446 |
+
onAddModels(providerId, models)
|
| 447 |
+
onMessage(
|
| 448 |
+
`Added ${models.length} model id${models.length === 1 ? "" : "s"} to ${providerId}. Apply to save.`,
|
| 449 |
+
"ok",
|
| 450 |
+
)
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
const toggleModel = (index: number, model: string) => {
|
| 454 |
+
const current = instances[index]?.models ?? []
|
| 455 |
+
updateInstance(
|
| 456 |
+
index,
|
| 457 |
+
current.includes(model)
|
| 458 |
+
? { models: current.filter((candidate) => candidate !== model) }
|
| 459 |
+
: { models: [...current, model] },
|
| 460 |
+
)
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
const useModelAsDefault = (index: number, model: string) => {
|
| 464 |
const route = `${providerIdFor(index)}/${model}`
|
| 465 |
onValuesChange({ ...values, MODEL: route })
|
| 466 |
+
const current = instances[index]?.models ?? []
|
| 467 |
+
if (!current.includes(model)) {
|
| 468 |
+
updateInstance(index, { models: [...current, model] })
|
| 469 |
+
}
|
| 470 |
onMessage(`Default model set to ${route}. Apply to save.`, "ok")
|
| 471 |
}
|
| 472 |
|
|
|
|
| 480 |
|
| 481 |
const legacyBaseUrl = values["OPENAI_COMPATIBLE_BASE_URL"] ?? ""
|
| 482 |
const legacyKey = values["OPENAI_COMPATIBLE_API_KEY"] ?? ""
|
| 483 |
+
const cardModels = (index: number): string[] => {
|
| 484 |
+
const providerId = providerIdFor(index)
|
| 485 |
+
return [
|
| 486 |
+
...new Set([
|
| 487 |
+
...(instances[index]?.models ?? []),
|
| 488 |
+
...(modelsByProvider[providerId] ?? []),
|
| 489 |
+
]),
|
| 490 |
+
]
|
| 491 |
+
}
|
| 492 |
+
const totalModels = instances.reduce(
|
| 493 |
+
(sum, _instance, index) => sum + cardModels(index).length,
|
| 494 |
+
0,
|
| 495 |
+
)
|
| 496 |
|
| 497 |
return (
|
| 498 |
<section className="space-y-4">
|
| 499 |
<div>
|
| 500 |
<h3 className="text-lg font-semibold">Endpoints</h3>
|
| 501 |
+
<p className="mt-1 text-sm text-muted-foreground">
|
| 502 |
Add any OpenAI-compatible server (vLLM, LM Studio, Ollama, Together, or
|
| 503 |
your own gateway). Each endpoint becomes a numbered provider:{" "}
|
| 504 |
<code>openai_compatible_1/<model></code>,{" "}
|
| 505 |
+
<code>openai_compatible_2/<model></code>, … Add as many model ids
|
| 506 |
+
as you want per endpoint, comma-separated, or use{" "}
|
| 507 |
+
<strong>Fetch models</strong> to pull them from the endpoint's{" "}
|
| 508 |
+
<code>/models</code> route. Tick the models you want applied — selected
|
| 509 |
+
ids are saved with the endpoint on <strong>Apply</strong> and appear in
|
| 510 |
+
the Model Config dropdowns after a reload.
|
| 511 |
</p>
|
| 512 |
+
<div className="mt-3 flex flex-wrap gap-2">
|
| 513 |
+
<Badge variant="outline" className="gap-1.5">
|
| 514 |
+
<Server className="size-3.5 text-muted-foreground" />
|
| 515 |
+
{instances.length} endpoint{instances.length === 1 ? "" : "s"}
|
| 516 |
+
</Badge>
|
| 517 |
+
<Badge variant="outline" className="gap-1.5">
|
| 518 |
+
<ListChecks className="size-3.5 text-muted-foreground" />
|
| 519 |
+
{totalModels} model id{totalModels === 1 ? "" : "s"}
|
| 520 |
+
</Badge>
|
| 521 |
+
</div>
|
| 522 |
</div>
|
| 523 |
|
| 524 |
{instances.length === 0 && legacyBaseUrl.trim() ? (
|
|
|
|
| 536 |
commit([{ base_url: legacyBaseUrl, api_keys: legacyKey, proxy: "" }])
|
| 537 |
}
|
| 538 |
>
|
| 539 |
+
<ListPlus />
|
| 540 |
Import as endpoint 1
|
| 541 |
</Button>
|
| 542 |
</div>
|
|
|
|
| 544 |
|
| 545 |
{instances.length === 0 ? (
|
| 546 |
<Card className="border-dashed">
|
| 547 |
+
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
| 548 |
+
<span className="flex size-12 items-center justify-center rounded-full bg-primary/10">
|
| 549 |
+
<Server className="size-6 text-primary" />
|
| 550 |
+
</span>
|
| 551 |
<p className="text-sm text-muted-foreground">
|
| 552 |
No endpoints yet. Add your first OpenAI-compatible endpoint.
|
| 553 |
</p>
|
| 554 |
<Button type="button" onClick={addInstance}>
|
| 555 |
+
<Plus />
|
| 556 |
Add endpoint
|
| 557 |
</Button>
|
| 558 |
</CardContent>
|
|
|
|
| 570 |
locked={field.locked}
|
| 571 |
testing={testingId === providerId}
|
| 572 |
fetching={fetchingId === providerId}
|
| 573 |
+
models={cardModels(index)}
|
| 574 |
+
selected={instance.models ?? []}
|
| 575 |
+
currentDefault={values["MODEL"] ?? ""}
|
| 576 |
onPatch={(patch) => updateInstance(index, patch)}
|
| 577 |
onRemove={() => removeInstance(index)}
|
| 578 |
onTest={() => {
|
|
|
|
| 580 |
onTestProvider(providerId, () => setTestingId(null))
|
| 581 |
}}
|
| 582 |
onFetch={() => void fetchModels(index)}
|
| 583 |
+
onClearModels={() => {
|
| 584 |
setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
|
| 585 |
+
updateInstance(index, { models: [] })
|
| 586 |
+
}}
|
| 587 |
+
onAddModels={(models) => addModels(index, models)}
|
| 588 |
+
onToggleModel={(model) => toggleModel(index, model)}
|
| 589 |
onUseModel={(model) => useModelAsDefault(index, model)}
|
| 590 |
/>
|
| 591 |
)
|
| 592 |
})}
|
| 593 |
+
<Button
|
| 594 |
+
type="button"
|
| 595 |
+
variant="outline"
|
| 596 |
+
className="w-full border-dashed"
|
| 597 |
+
onClick={addInstance}
|
| 598 |
+
>
|
| 599 |
+
<Plus />
|
| 600 |
+
Add endpoint
|
| 601 |
+
</Button>
|
| 602 |
</div>
|
| 603 |
)}
|
| 604 |
</section>
|
|
@@ -1,7 +1,10 @@
|
|
| 1 |
import { useState } from "react"
|
|
|
|
| 2 |
|
| 3 |
import { statusClass } from "@/App"
|
|
|
|
| 4 |
import type { ProviderStatus } from "@/lib/types"
|
|
|
|
| 5 |
import { Badge } from "./ui/badge"
|
| 6 |
import { Button } from "./ui/button"
|
| 7 |
import { Card } from "./ui/card"
|
|
@@ -26,25 +29,44 @@ export function ProviderCard({ provider, state, onTest }: ProviderCardProps) {
|
|
| 26 |
: provider.configuration || ""
|
| 27 |
|
| 28 |
const label = isLocal ? "Test" : "Refresh models"
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
-
<Card className="flex flex-row items-center justify-between gap-3 p-4">
|
| 32 |
-
<div className="min-w-0">
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
-
<p className="mt-1 truncate text-xs text-muted-foreground">{meta}</p>
|
| 38 |
</div>
|
| 39 |
<Button
|
| 40 |
variant="secondary"
|
| 41 |
size="sm"
|
|
|
|
| 42 |
disabled={pending}
|
| 43 |
onClick={() => {
|
| 44 |
setPending(true)
|
| 45 |
onTest(provider.provider_id, () => setPending(false))
|
| 46 |
}}
|
| 47 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
{pending ? (isLocal ? "Testing" : "Refreshing") : label}
|
| 49 |
</Button>
|
| 50 |
</Card>
|
|
@@ -88,10 +110,26 @@ export function ConnectedAccountCard({
|
|
| 88 |
const target = status.authorization_url || status.verification_url
|
| 89 |
|
| 90 |
return (
|
| 91 |
-
<Card className="flex flex-col gap-3 p-4">
|
| 92 |
-
<div className="flex items-center gap-
|
| 93 |
-
<
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
<p className="text-xs text-muted-foreground">{statusMessage(status)}</p>
|
| 97 |
<div className="flex flex-wrap gap-2">
|
|
@@ -135,6 +173,22 @@ export function ConnectedAccountCard({
|
|
| 135 |
)
|
| 136 |
}
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
function statusMessage(status: ConnectedState): string {
|
| 139 |
if (status.connected) {
|
| 140 |
const identity = status.email || "ChatGPT subscription connected"
|
|
@@ -151,4 +205,4 @@ function statusMessage(status: ConnectedState): string {
|
|
| 151 |
return "Finish signing in, then return to this page."
|
| 152 |
}
|
| 153 |
return status.message || "Connect a ChatGPT account to discover subscription models."
|
| 154 |
-
}
|
|
|
|
| 1 |
import { useState } from "react"
|
| 2 |
+
import { Cable, Cpu, KeyRound, RefreshCw } from "lucide-react"
|
| 3 |
|
| 4 |
import { statusClass } from "@/App"
|
| 5 |
+
import { statusDotClass } from "@/lib/status"
|
| 6 |
import type { ProviderStatus } from "@/lib/types"
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
import { Badge } from "./ui/badge"
|
| 9 |
import { Button } from "./ui/button"
|
| 10 |
import { Card } from "./ui/card"
|
|
|
|
| 29 |
: provider.configuration || ""
|
| 30 |
|
| 31 |
const label = isLocal ? "Test" : "Refresh models"
|
| 32 |
+
const Icon = isLocal ? Cpu : Cable
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<Card className="flex flex-row items-center justify-between gap-3 p-4 transition-shadow hover:shadow-md">
|
| 36 |
+
<div className="flex min-w-0 items-center gap-3">
|
| 37 |
+
<span
|
| 38 |
+
className={cn(
|
| 39 |
+
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
| 40 |
+
isLocal ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
| 41 |
+
)}
|
| 42 |
+
>
|
| 43 |
+
<Icon className="size-4.5" />
|
| 44 |
+
</span>
|
| 45 |
+
<div className="min-w-0">
|
| 46 |
+
<div className="flex items-center gap-2">
|
| 47 |
+
<strong className="truncate text-sm">
|
| 48 |
+
{provider.display_name || provider.provider_id}
|
| 49 |
+
</strong>
|
| 50 |
+
<StatusBadge status={state.status} label={state.label} />
|
| 51 |
+
</div>
|
| 52 |
+
<p className="mt-1 truncate text-xs text-muted-foreground">{meta}</p>
|
| 53 |
</div>
|
|
|
|
| 54 |
</div>
|
| 55 |
<Button
|
| 56 |
variant="secondary"
|
| 57 |
size="sm"
|
| 58 |
+
className="shrink-0"
|
| 59 |
disabled={pending}
|
| 60 |
onClick={() => {
|
| 61 |
setPending(true)
|
| 62 |
onTest(provider.provider_id, () => setPending(false))
|
| 63 |
}}
|
| 64 |
>
|
| 65 |
+
{pending ? (
|
| 66 |
+
<RefreshCw className="animate-spin" />
|
| 67 |
+
) : (
|
| 68 |
+
<Icon className="size-4" />
|
| 69 |
+
)}
|
| 70 |
{pending ? (isLocal ? "Testing" : "Refreshing") : label}
|
| 71 |
</Button>
|
| 72 |
</Card>
|
|
|
|
| 110 |
const target = status.authorization_url || status.verification_url
|
| 111 |
|
| 112 |
return (
|
| 113 |
+
<Card className="flex flex-col gap-3 p-4 transition-shadow hover:shadow-md">
|
| 114 |
+
<div className="flex items-center gap-3">
|
| 115 |
+
<span
|
| 116 |
+
className={cn(
|
| 117 |
+
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
| 118 |
+
connected
|
| 119 |
+
? "bg-emerald-500/10 text-emerald-600"
|
| 120 |
+
: connecting
|
| 121 |
+
? "bg-amber-500/10 text-amber-600"
|
| 122 |
+
: "bg-muted text-muted-foreground",
|
| 123 |
+
)}
|
| 124 |
+
>
|
| 125 |
+
<KeyRound className="size-4.5" />
|
| 126 |
+
</span>
|
| 127 |
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
| 128 |
+
<strong className="truncate text-sm">
|
| 129 |
+
{provider.display_name || provider.provider_id}
|
| 130 |
+
</strong>
|
| 131 |
+
<StatusBadge status={status.state} label={status.label} />
|
| 132 |
+
</div>
|
| 133 |
</div>
|
| 134 |
<p className="text-xs text-muted-foreground">{statusMessage(status)}</p>
|
| 135 |
<div className="flex flex-wrap gap-2">
|
|
|
|
| 173 |
)
|
| 174 |
}
|
| 175 |
|
| 176 |
+
function StatusBadge({ status, label }: { status: string; label: string }) {
|
| 177 |
+
const connecting = status === "connecting"
|
| 178 |
+
return (
|
| 179 |
+
<Badge variant={statusClass(status)} className="gap-1.5">
|
| 180 |
+
<span
|
| 181 |
+
className={cn(
|
| 182 |
+
"size-1.5 rounded-full",
|
| 183 |
+
statusDotClass(status),
|
| 184 |
+
connecting && "animate-pulse",
|
| 185 |
+
)}
|
| 186 |
+
/>
|
| 187 |
+
{label}
|
| 188 |
+
</Badge>
|
| 189 |
+
)
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
function statusMessage(status: ConnectedState): string {
|
| 193 |
if (status.connected) {
|
| 194 |
const identity = status.email || "ChatGPT subscription connected"
|
|
|
|
| 205 |
return "Finish signing in, then return to this page."
|
| 206 |
}
|
| 207 |
return status.message || "Connect a ChatGPT account to discover subscription models."
|
| 208 |
+
}
|
|
@@ -1,47 +1,100 @@
|
|
|
|
|
|
|
|
| 1 |
import { cn } from "@/lib/utils"
|
| 2 |
import type { AdminConfig } from "@/lib/types"
|
| 3 |
|
| 4 |
import { VIEW_GROUPS } from "../App"
|
|
|
|
|
|
|
| 5 |
|
| 6 |
interface SidebarProps {
|
| 7 |
config: AdminConfig
|
| 8 |
activeView: string
|
| 9 |
onSelectView: (view: string) => void
|
|
|
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
-
export function Sidebar({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
return (
|
| 14 |
-
<aside
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
<div>
|
| 20 |
-
<h1 className="text-
|
|
|
|
|
|
|
| 21 |
<p className="text-xs text-muted-foreground">Server Control</p>
|
| 22 |
</div>
|
| 23 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
<nav className="flex flex-col gap-1 px-3" aria-label="Admin views">
|
| 25 |
-
{VIEW_GROUPS.map((view) =>
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</nav>
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</div>
|
| 45 |
</aside>
|
| 46 |
)
|
| 47 |
-
}
|
|
|
|
| 1 |
+
import { Bot, FileCog } from "lucide-react"
|
| 2 |
+
|
| 3 |
import { cn } from "@/lib/utils"
|
| 4 |
import type { AdminConfig } from "@/lib/types"
|
| 5 |
|
| 6 |
import { VIEW_GROUPS } from "../App"
|
| 7 |
+
import { Separator } from "./ui/separator"
|
| 8 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
| 9 |
|
| 10 |
interface SidebarProps {
|
| 11 |
config: AdminConfig
|
| 12 |
activeView: string
|
| 13 |
onSelectView: (view: string) => void
|
| 14 |
+
/** Called after a view is selected; used by the mobile drawer to close. */
|
| 15 |
+
onNavigate?: () => void
|
| 16 |
+
className?: string
|
| 17 |
}
|
| 18 |
|
| 19 |
+
export function Sidebar({
|
| 20 |
+
config,
|
| 21 |
+
activeView,
|
| 22 |
+
onSelectView,
|
| 23 |
+
onNavigate,
|
| 24 |
+
className,
|
| 25 |
+
}: SidebarProps) {
|
| 26 |
return (
|
| 27 |
+
<aside
|
| 28 |
+
className={cn(
|
| 29 |
+
"flex w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground",
|
| 30 |
+
className,
|
| 31 |
+
)}
|
| 32 |
+
>
|
| 33 |
+
<div className="flex items-center gap-3 px-5 py-5">
|
| 34 |
+
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/70 text-primary-foreground shadow-sm">
|
| 35 |
+
<Bot className="size-5" />
|
| 36 |
</div>
|
| 37 |
+
<div className="min-w-0">
|
| 38 |
+
<h1 className="truncate text-sm leading-tight font-semibold">
|
| 39 |
+
Free Claude Code
|
| 40 |
+
</h1>
|
| 41 |
<p className="text-xs text-muted-foreground">Server Control</p>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
+
|
| 45 |
+
<div className="px-5 pt-1 pb-2 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase">
|
| 46 |
+
Configuration
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
<nav className="flex flex-col gap-1 px-3" aria-label="Admin views">
|
| 50 |
+
{VIEW_GROUPS.map((view) => {
|
| 51 |
+
const isActive = view.id === activeView
|
| 52 |
+
const Icon = view.icon
|
| 53 |
+
return (
|
| 54 |
+
<Tooltip key={view.id}>
|
| 55 |
+
<TooltipTrigger asChild>
|
| 56 |
+
<button
|
| 57 |
+
type="button"
|
| 58 |
+
className={cn(
|
| 59 |
+
"relative flex items-center gap-3 rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
|
| 60 |
+
isActive
|
| 61 |
+
? "bg-accent text-accent-foreground"
|
| 62 |
+
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
| 63 |
+
)}
|
| 64 |
+
aria-current={isActive ? "page" : undefined}
|
| 65 |
+
onClick={() => {
|
| 66 |
+
onSelectView(view.id)
|
| 67 |
+
onNavigate?.()
|
| 68 |
+
}}
|
| 69 |
+
>
|
| 70 |
+
{isActive ? (
|
| 71 |
+
<span className="absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-primary" />
|
| 72 |
+
) : null}
|
| 73 |
+
<Icon
|
| 74 |
+
className={cn(
|
| 75 |
+
"size-4 shrink-0",
|
| 76 |
+
isActive ? "text-primary" : "text-muted-foreground",
|
| 77 |
+
)}
|
| 78 |
+
/>
|
| 79 |
+
{view.label}
|
| 80 |
+
</button>
|
| 81 |
+
</TooltipTrigger>
|
| 82 |
+
<TooltipContent side="right">{view.description}</TooltipContent>
|
| 83 |
+
</Tooltip>
|
| 84 |
+
)
|
| 85 |
+
})}
|
| 86 |
</nav>
|
| 87 |
+
|
| 88 |
+
<div className="mt-auto space-y-2 px-5 py-4">
|
| 89 |
+
<Separator />
|
| 90 |
+
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
| 91 |
+
<FileCog className="size-3.5 shrink-0" />
|
| 92 |
+
Managed config
|
| 93 |
+
</div>
|
| 94 |
+
<p className="break-all font-mono text-[11px] leading-relaxed text-muted-foreground/80">
|
| 95 |
+
{config.paths.managed}
|
| 96 |
+
</p>
|
| 97 |
</div>
|
| 98 |
</aside>
|
| 99 |
)
|
| 100 |
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Moon, Sun } from "lucide-react"
|
| 2 |
+
|
| 3 |
+
import { useTheme } from "./theme-provider"
|
| 4 |
+
import { Button } from "./ui/button"
|
| 5 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
| 6 |
+
|
| 7 |
+
export function ThemeToggle() {
|
| 8 |
+
const { resolvedTheme, toggleTheme } = useTheme()
|
| 9 |
+
const isDark = resolvedTheme === "dark"
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<Tooltip>
|
| 13 |
+
<TooltipTrigger asChild>
|
| 14 |
+
<Button
|
| 15 |
+
type="button"
|
| 16 |
+
variant="ghost"
|
| 17 |
+
size="icon"
|
| 18 |
+
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
| 19 |
+
onClick={toggleTheme}
|
| 20 |
+
>
|
| 21 |
+
{isDark ? <Sun /> : <Moon />}
|
| 22 |
+
</Button>
|
| 23 |
+
</TooltipTrigger>
|
| 24 |
+
<TooltipContent side="bottom">
|
| 25 |
+
{isDark ? "Switch to light theme" : "Switch to dark theme"}
|
| 26 |
+
</TooltipContent>
|
| 27 |
+
</Tooltip>
|
| 28 |
+
)
|
| 29 |
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useEffect, useState } from "react"
|
| 2 |
+
import type { ReactNode } from "react"
|
| 3 |
+
|
| 4 |
+
const THEME_KEY = "fcc-admin-theme"
|
| 5 |
+
|
| 6 |
+
export type Theme = "light" | "dark" | "system"
|
| 7 |
+
|
| 8 |
+
interface ThemeContextValue {
|
| 9 |
+
theme: Theme
|
| 10 |
+
/** Resolved appearance currently applied to the document. */
|
| 11 |
+
resolvedTheme: "light" | "dark"
|
| 12 |
+
setTheme: (theme: Theme) => void
|
| 13 |
+
toggleTheme: () => void
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
| 17 |
+
|
| 18 |
+
function readStoredTheme(): Theme {
|
| 19 |
+
try {
|
| 20 |
+
const stored = window.localStorage.getItem(THEME_KEY)
|
| 21 |
+
if (stored === "light" || stored === "dark" || stored === "system") return stored
|
| 22 |
+
} catch {
|
| 23 |
+
// Storage may be unavailable (private mode); fall back to system.
|
| 24 |
+
}
|
| 25 |
+
return "system"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function readSystemTheme(): "light" | "dark" {
|
| 29 |
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function applyTheme(theme: Theme): "light" | "dark" {
|
| 33 |
+
const resolved = theme === "system" ? readSystemTheme() : theme
|
| 34 |
+
document.documentElement.classList.toggle("dark", resolved === "dark")
|
| 35 |
+
return resolved
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
| 39 |
+
const [theme, setThemeState] = useState<Theme>(readStoredTheme)
|
| 40 |
+
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() =>
|
| 41 |
+
applyTheme(readStoredTheme()),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
setResolvedTheme(applyTheme(theme))
|
| 46 |
+
if (theme !== "system") return
|
| 47 |
+
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
| 48 |
+
const onChange = () => setResolvedTheme(applyTheme("system"))
|
| 49 |
+
media.addEventListener("change", onChange)
|
| 50 |
+
return () => media.removeEventListener("change", onChange)
|
| 51 |
+
}, [theme])
|
| 52 |
+
|
| 53 |
+
const setTheme = (next: Theme) => {
|
| 54 |
+
setThemeState(next)
|
| 55 |
+
try {
|
| 56 |
+
window.localStorage.setItem(THEME_KEY, next)
|
| 57 |
+
} catch {
|
| 58 |
+
// Non-persistent storage: the toggle still applies for this session.
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const toggleTheme = () => {
|
| 63 |
+
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
| 68 |
+
{children}
|
| 69 |
+
</ThemeContext.Provider>
|
| 70 |
+
)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function useTheme(): ThemeContextValue {
|
| 74 |
+
const context = useContext(ThemeContext)
|
| 75 |
+
if (!context) {
|
| 76 |
+
throw new Error("useTheme must be used within a ThemeProvider")
|
| 77 |
+
}
|
| 78 |
+
return context
|
| 79 |
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
function Separator({
|
| 7 |
+
className,
|
| 8 |
+
orientation = "horizontal",
|
| 9 |
+
decorative = true,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<SeparatorPrimitive.Root
|
| 14 |
+
data-slot="separator"
|
| 15 |
+
decorative={decorative}
|
| 16 |
+
orientation={orientation}
|
| 17 |
+
className={cn(
|
| 18 |
+
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
| 19 |
+
className,
|
| 20 |
+
)}
|
| 21 |
+
{...props}
|
| 22 |
+
/>
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export { Separator }
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cn } from "@/lib/utils"
|
| 2 |
+
|
| 3 |
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
| 4 |
+
return (
|
| 5 |
+
<div
|
| 6 |
+
data-slot="skeleton"
|
| 7 |
+
className={cn("bg-accent animate-pulse rounded-md", className)}
|
| 8 |
+
{...props}
|
| 9 |
+
/>
|
| 10 |
+
)
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export { Skeleton }
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
function TooltipProvider({
|
| 7 |
+
delayDuration = 0,
|
| 8 |
+
...props
|
| 9 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
| 10 |
+
return (
|
| 11 |
+
<TooltipPrimitive.Provider
|
| 12 |
+
data-slot="tooltip-provider"
|
| 13 |
+
delayDuration={delayDuration}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
| 20 |
+
return (
|
| 21 |
+
<TooltipProvider>
|
| 22 |
+
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
| 23 |
+
</TooltipProvider>
|
| 24 |
+
)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function TooltipTrigger({
|
| 28 |
+
...props
|
| 29 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
| 30 |
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function TooltipContent({
|
| 34 |
+
className,
|
| 35 |
+
sideOffset = 6,
|
| 36 |
+
children,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
| 39 |
+
return (
|
| 40 |
+
<TooltipPrimitive.Portal>
|
| 41 |
+
<TooltipPrimitive.Content
|
| 42 |
+
data-slot="tooltip-content"
|
| 43 |
+
sideOffset={sideOffset}
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
| 46 |
+
className,
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
{children}
|
| 51 |
+
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
| 52 |
+
</TooltipPrimitive.Content>
|
| 53 |
+
</TooltipPrimitive.Portal>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
@@ -1,4 +1,5 @@
|
|
| 1 |
@import "tailwindcss";
|
|
|
|
| 2 |
|
| 3 |
@custom-variant dark (&:is(.dark *));
|
| 4 |
|
|
@@ -83,7 +84,23 @@
|
|
| 83 |
border-color: var(--border);
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
body {
|
| 87 |
background-color: var(--background);
|
| 88 |
color: var(--foreground);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
|
| 4 |
@custom-variant dark (&:is(.dark *));
|
| 5 |
|
|
|
|
| 84 |
border-color: var(--border);
|
| 85 |
}
|
| 86 |
|
| 87 |
+
html {
|
| 88 |
+
color-scheme: light;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
html.dark {
|
| 92 |
+
color-scheme: dark;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
body {
|
| 96 |
background-color: var(--background);
|
| 97 |
color: var(--foreground);
|
| 98 |
+
-webkit-font-smoothing: antialiased;
|
| 99 |
+
text-rendering: optimizeLegibility;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Slim scrollbars for dense lists (model pickers, endpoint model lists). */
|
| 103 |
+
* {
|
| 104 |
+
scrollbar-width: thin;
|
| 105 |
+
scrollbar-color: var(--border) transparent;
|
| 106 |
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Dot color for a provider/endpoint status badge, shared by all status cards. */
|
| 2 |
+
export function statusDotClass(status: string | undefined): string {
|
| 3 |
+
if (["configured", "reachable", "running", "connected"].includes(status ?? ""))
|
| 4 |
+
return "bg-emerald-500"
|
| 5 |
+
if (
|
| 6 |
+
["missing_key", "missing_config", "missing_url", "unknown", "connecting"].includes(
|
| 7 |
+
status ?? "",
|
| 8 |
+
)
|
| 9 |
+
)
|
| 10 |
+
return "bg-amber-500"
|
| 11 |
+
if (["offline", "error"].includes(status ?? "")) return "bg-red-500"
|
| 12 |
+
return "bg-muted-foreground/50"
|
| 13 |
+
}
|
|
@@ -113,4 +113,6 @@ export interface OpenAICompatibleInstance {
|
|
| 113 |
base_url: string
|
| 114 |
api_keys: string
|
| 115 |
proxy: string
|
|
|
|
|
|
|
| 116 |
}
|
|
|
|
| 113 |
base_url: string
|
| 114 |
api_keys: string
|
| 115 |
proxy: string
|
| 116 |
+
/** Model ids applied to this endpoint; persisted on Apply. */
|
| 117 |
+
models?: string[]
|
| 118 |
}
|
|
@@ -2,10 +2,13 @@ import { StrictMode } from "react"
|
|
| 2 |
import { createRoot } from "react-dom/client"
|
| 3 |
|
| 4 |
import App from "./App"
|
|
|
|
| 5 |
import "./index.css"
|
| 6 |
|
| 7 |
createRoot(document.getElementById("root")!).render(
|
| 8 |
<StrictMode>
|
| 9 |
-
<
|
|
|
|
|
|
|
| 10 |
</StrictMode>,
|
| 11 |
-
)
|
|
|
|
| 2 |
import { createRoot } from "react-dom/client"
|
| 3 |
|
| 4 |
import App from "./App"
|
| 5 |
+
import { ThemeProvider } from "./components/theme-provider"
|
| 6 |
import "./index.css"
|
| 7 |
|
| 8 |
createRoot(document.getElementById("root")!).render(
|
| 9 |
<StrictMode>
|
| 10 |
+
<ThemeProvider>
|
| 11 |
+
<App />
|
| 12 |
+
</ThemeProvider>
|
| 13 |
</StrictMode>,
|
| 14 |
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "4.
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "4.25.0"
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
@@ -254,18 +254,26 @@ def _model_options(
|
|
| 254 |
*,
|
| 255 |
refresh_result: ProviderModelRefreshResult | None = None,
|
| 256 |
) -> dict[str, list[str]]:
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
for ref in configured_chat_model_refs(services.requests.current_settings())
|
| 260 |
-
}
|
| 261 |
discovered = {
|
| 262 |
info.model_id for info in services.requests.cached_prefixed_model_infos()
|
| 263 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
failed_provider_ids = (
|
| 265 |
refresh_result.failed_provider_ids if refresh_result is not None else ()
|
| 266 |
)
|
| 267 |
return {
|
| 268 |
-
"models": sorted(
|
|
|
|
|
|
|
|
|
|
| 269 |
"failed_providers": list(failed_provider_ids),
|
| 270 |
}
|
| 271 |
|
|
|
|
| 254 |
*,
|
| 255 |
refresh_result: ProviderModelRefreshResult | None = None,
|
| 256 |
) -> dict[str, list[str]]:
|
| 257 |
+
settings = services.requests.current_settings()
|
| 258 |
+
configured = {ref.model_ref for ref in configured_chat_model_refs(settings)}
|
|
|
|
|
|
|
| 259 |
discovered = {
|
| 260 |
info.model_id for info in services.requests.cached_prefixed_model_infos()
|
| 261 |
}
|
| 262 |
+
stored_instance_models = {
|
| 263 |
+
f"openai_compatible_{index}/{model.strip()}"
|
| 264 |
+
for index, instance in enumerate(settings.openai_compatible_instances, start=1)
|
| 265 |
+
if instance.base_url.strip()
|
| 266 |
+
for model in instance.models
|
| 267 |
+
if model.strip()
|
| 268 |
+
}
|
| 269 |
failed_provider_ids = (
|
| 270 |
refresh_result.failed_provider_ids if refresh_result is not None else ()
|
| 271 |
)
|
| 272 |
return {
|
| 273 |
+
"models": sorted(
|
| 274 |
+
configured | discovered | stored_instance_models,
|
| 275 |
+
key=str.casefold,
|
| 276 |
+
),
|
| 277 |
"failed_providers": list(failed_provider_ids),
|
| 278 |
}
|
| 279 |
|
|
@@ -1 +0,0 @@
|
|
| 1 |
-
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-800:oklch(43.2% .095 166.913);--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.inset-y-0{inset-block:0}.right-2{right:calc(var(--spacing) * 2)}.z-10{z-index:10}.mt-1{margin-top:var(--spacing)}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-9{width:calc(var(--spacing) * 9)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-input{border-color:var(--input)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--accent)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-background{background-color:var(--background)}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-transparent{background-color:#0000}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-10{padding-block:calc(var(--spacing) * 10)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-800{color:var(--color-emerald-800)}.text-foreground{color:var(--foreground)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground{color:var(--sidebar-foreground)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media(hover:hover){.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}}:root{--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(95.6% .012 81.69);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(90.9% .01 58.071);--input:oklch(90.9% .01 58.071);--ring:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% .001 106.423);--sidebar-foreground:oklch(14.7% .004 49.25)}.dark{--background:oklch(14.7% .004 285.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(85.1% .2 91.936);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(77.5% .024 90.004);--accent:oklch(31.7% .012 45.683);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.7% .222 23.322);--border:oklch(100% 0 0/.15);--input:oklch(100% 0 0/.3);--ring:oklch(95.2% .129 103.88);--sidebar:oklch(21.6% .006 56.043);--sidebar-foreground:oklch(98.5% .001 106.423)}:root{--radius:.625rem}*{border-color:var(--border)}body{background-color:var(--background);color:var(--foreground)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}
|
|
|
|
|
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-800:oklch(43.2% .095 166.913);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-y-0{inset-block:0}.inset-y-1\.5{inset-block:calc(var(--spacing) * 1.5)}.top-0{top:0}.top-4{top:calc(var(--spacing) * 4)}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.z-10{z-index:10}.z-30{z-index:30}.z-50{z-index:50}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-4\.5{width:calc(var(--spacing) * 4.5);height:calc(var(--spacing) * 4.5)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.h-4{height:calc(var(--spacing) * 4)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-36{height:calc(var(--spacing) * 36)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-3\/6{width:50%}.w-4\/6{width:66.6667%}.w-5\/6{width:83.3333%}.w-9{width:calc(var(--spacing) * 9)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-fit{width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.origin-\(--radix-tooltip-content-transform-origin\){transform-origin:var(--radix-tooltip-content-transform-origin)}.translate-y-\[calc\(-50\%_-_2px\)\]{--tw-translate-y: calc(-50% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-45{rotate:45deg}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0{gap:0}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded-\[2px\]{border-radius:2px}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-input{border-color:var(--input)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--accent)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/10{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.bg-background,.bg-background\/80{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,var(--background) 80%,transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/90{background-color:color-mix(in oklab,var(--background) 90%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-border{background-color:var(--border)}.bg-card,.bg-card\/40{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/40{background-color:color-mix(in oklab,var(--card) 40%,transparent)}}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50\/70{background-color:#ecfdf5b3}@supports (color:color-mix(in lab,red,red)){.bg-emerald-50\/70{background-color:color-mix(in oklab,var(--color-emerald-50) 70%,transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/50{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/50{background-color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-transparent{background-color:#0000}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-primary{--tw-gradient-from:var(--primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-primary\/70{--tw-gradient-to:var(--primary)}@supports (color:color-mix(in lab,red,red)){.to-primary\/70{--tw-gradient-to:color-mix(in oklab, var(--primary) 70%, transparent)}}.to-primary\/70{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-amber-400{fill:var(--color-amber-400)}.fill-primary{fill:var(--primary)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-1{padding-top:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-balance{text-wrap:balance}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-800{color:var(--color-emerald-800)}.text-foreground{color:var(--foreground)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground{color:var(--sidebar-foreground)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-primary\/20{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/20{--tw-ring-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.running{animation-play-state:running}.slide-in-from-left{--tw-enter-translate-x:-100%}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media(hover:hover){.hover\:border-border:hover,.hover\:border-border\/80:hover{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.hover\:border-border\/80:hover{border-color:color-mix(in oklab,var(--border) 80%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}@media(min-width:40rem){.sm\:inline-flex{display:inline-flex}}@media(min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:inline{display:inline}}.dark\:border-emerald-500\/30:is(.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.dark\:border-emerald-500\/30:is(.dark *){border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.dark\:bg-emerald-500\/10:is(.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-emerald-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(95.6% .012 81.69);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(90.9% .01 58.071);--input:oklch(90.9% .01 58.071);--ring:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% .001 106.423);--sidebar-foreground:oklch(14.7% .004 49.25)}.dark{--background:oklch(14.7% .004 285.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(85.1% .2 91.936);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(77.5% .024 90.004);--accent:oklch(31.7% .012 45.683);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.7% .222 23.322);--border:oklch(100% 0 0/.15);--input:oklch(100% 0 0/.3);--ring:oklch(95.2% .129 103.88);--sidebar:oklch(21.6% .006 56.043);--sidebar-foreground:oklch(98.5% .001 106.423)}:root{--radius:.625rem}*{border-color:var(--border)}html{color-scheme:light}html.dark{color-scheme:dark}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility}*{scrollbar-width:thin;scrollbar-color:var(--border) transparent}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -5,8 +5,8 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<link rel="icon" href="data:," />
|
| 7 |
<title>Free Claude Code Admin</title>
|
| 8 |
-
<script type="module" crossorigin src="/admin/assets/admin-
|
| 9 |
-
<link rel="stylesheet" crossorigin href="/admin/assets/admin-
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="root"></div>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<link rel="icon" href="data:," />
|
| 7 |
<title>Free Claude Code Admin</title>
|
| 8 |
+
<script type="module" crossorigin src="/admin/assets/admin-D-op9N5Q.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="/admin/assets/admin-Brc4d8H7.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div id="root"></div>
|
|
@@ -114,6 +114,15 @@ def _openai_compatible_instance_statuses(raw: str) -> list[dict[str, Any]]:
|
|
| 114 |
continue
|
| 115 |
base_url = str(instance.get("base_url", "") or "")
|
| 116 |
configured = bool(base_url.strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
statuses.append(
|
| 118 |
{
|
| 119 |
"provider_id": f"openai_compatible_{index}",
|
|
@@ -123,6 +132,7 @@ def _openai_compatible_instance_statuses(raw: str) -> list[dict[str, Any]]:
|
|
| 123 |
"label": "Configured" if configured else "Missing base URL",
|
| 124 |
"base_url": base_url,
|
| 125 |
"configuration": "OPENAI_COMPATIBLE_BASE_URL",
|
|
|
|
| 126 |
}
|
| 127 |
)
|
| 128 |
return statuses
|
|
|
|
| 114 |
continue
|
| 115 |
base_url = str(instance.get("base_url", "") or "")
|
| 116 |
configured = bool(base_url.strip())
|
| 117 |
+
raw_models = instance.get("models")
|
| 118 |
+
if isinstance(raw_models, list):
|
| 119 |
+
models = [
|
| 120 |
+
str(model)
|
| 121 |
+
for model in raw_models
|
| 122 |
+
if isinstance(model, str) and model.strip()
|
| 123 |
+
]
|
| 124 |
+
else:
|
| 125 |
+
models = []
|
| 126 |
statuses.append(
|
| 127 |
{
|
| 128 |
"provider_id": f"openai_compatible_{index}",
|
|
|
|
| 132 |
"label": "Configured" if configured else "Missing base URL",
|
| 133 |
"base_url": base_url,
|
| 134 |
"configuration": "OPENAI_COMPATIBLE_BASE_URL",
|
| 135 |
+
"models": models,
|
| 136 |
}
|
| 137 |
)
|
| 138 |
return statuses
|
|
@@ -27,6 +27,11 @@ class OpenAICompatibleInstance(BaseModel):
|
|
| 27 |
base_url: str = ""
|
| 28 |
api_keys: str = ""
|
| 29 |
proxy: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
class Settings(BaseSettings):
|
|
|
|
| 27 |
base_url: str = ""
|
| 28 |
api_keys: str = ""
|
| 29 |
proxy: str = ""
|
| 30 |
+
# Model ids applied to this endpoint from the admin "OpenAI-Compatible
|
| 31 |
+
# Endpoints" page. Each id is routable as ``openai_compatible_N/<model>``
|
| 32 |
+
# and listed in the admin model options even when the endpoint's live
|
| 33 |
+
# ``/models`` route is unreachable.
|
| 34 |
+
models: tuple[str, ...] = ()
|
| 35 |
|
| 36 |
|
| 37 |
class Settings(BaseSettings):
|
|
@@ -16,7 +16,7 @@ from free_claude_code.application.model_metadata import (
|
|
| 16 |
)
|
| 17 |
from free_claude_code.config.admin.values import MASKED_SECRET
|
| 18 |
from free_claude_code.config.server_urls import local_admin_url
|
| 19 |
-
from free_claude_code.config.settings import Settings
|
| 20 |
from tests.api.support import create_test_app, provider_manager_for_app
|
| 21 |
|
| 22 |
|
|
@@ -365,6 +365,19 @@ def test_admin_static_model_combobox_preserves_custom_slugs_and_none_semantics()
|
|
| 365 |
assert '"warn"' in app_source
|
| 366 |
|
| 367 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
|
| 369 |
_set_home(monkeypatch, tmp_path)
|
| 370 |
_clear_process_config(monkeypatch)
|
|
@@ -490,6 +503,35 @@ def test_admin_models_include_configured_and_cached_canonical_slugs():
|
|
| 490 |
}
|
| 491 |
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
def test_admin_model_refresh_returns_the_updated_canonical_catalog():
|
| 494 |
settings = Settings()
|
| 495 |
settings.model = "deepseek/deepseek-chat"
|
|
|
|
| 16 |
)
|
| 17 |
from free_claude_code.config.admin.values import MASKED_SECRET
|
| 18 |
from free_claude_code.config.server_urls import local_admin_url
|
| 19 |
+
from free_claude_code.config.settings import OpenAICompatibleInstance, Settings
|
| 20 |
from tests.api.support import create_test_app, provider_manager_for_app
|
| 21 |
|
| 22 |
|
|
|
|
| 365 |
assert '"warn"' in app_source
|
| 366 |
|
| 367 |
|
| 368 |
+
def test_admin_openai_compatible_view_supports_manual_model_entries():
|
| 369 |
+
view = Path("frontend/src/components/OpenAICompatibleView.tsx").read_text(
|
| 370 |
+
encoding="utf-8"
|
| 371 |
+
)
|
| 372 |
+
app_source = Path("frontend/src/App.tsx").read_text(encoding="utf-8")
|
| 373 |
+
assert "Add model id, comma-separated" in view
|
| 374 |
+
assert 'split(",")' in view
|
| 375 |
+
assert "onAddModels" in view
|
| 376 |
+
assert "Use as default" in view
|
| 377 |
+
assert "handleAddEndpointModels" in app_source
|
| 378 |
+
assert "onAddModels={handleAddEndpointModels}" in app_source
|
| 379 |
+
|
| 380 |
+
|
| 381 |
def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
|
| 382 |
_set_home(monkeypatch, tmp_path)
|
| 383 |
_clear_process_config(monkeypatch)
|
|
|
|
| 503 |
}
|
| 504 |
|
| 505 |
|
| 506 |
+
def test_admin_models_include_stored_openai_compatible_instance_models():
|
| 507 |
+
"""Applied per-endpoint model ids are listed even without live discovery."""
|
| 508 |
+
settings = Settings()
|
| 509 |
+
settings.model = "nvidia_nim/configured-model"
|
| 510 |
+
settings.openai_compatible_instances = (
|
| 511 |
+
OpenAICompatibleInstance(
|
| 512 |
+
base_url="https://a.example/v1",
|
| 513 |
+
models=("gpt-4o", "deepseek-v3"),
|
| 514 |
+
),
|
| 515 |
+
OpenAICompatibleInstance(base_url=""),
|
| 516 |
+
OpenAICompatibleInstance(
|
| 517 |
+
base_url="https://b.example",
|
| 518 |
+
models=(" local-model ", ""),
|
| 519 |
+
),
|
| 520 |
+
)
|
| 521 |
+
app = create_test_app(settings)
|
| 522 |
+
response = _local_client(app).get("/admin/api/models")
|
| 523 |
+
assert response.status_code == 200
|
| 524 |
+
assert response.json() == {
|
| 525 |
+
"models": [
|
| 526 |
+
"nvidia_nim/configured-model",
|
| 527 |
+
"openai_compatible_1/deepseek-v3",
|
| 528 |
+
"openai_compatible_1/gpt-4o",
|
| 529 |
+
"openai_compatible_3/local-model",
|
| 530 |
+
],
|
| 531 |
+
"failed_providers": [],
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
|
| 535 |
def test_admin_model_refresh_returns_the_updated_canonical_catalog():
|
| 536 |
settings = Settings()
|
| 537 |
settings.model = "deepseek/deepseek-chat"
|
|
@@ -673,6 +673,31 @@ class TestOpenAICompatibleInstances:
|
|
| 673 |
),
|
| 674 |
)
|
| 675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
def test_instances_empty_json_array(self, monkeypatch):
|
| 677 |
"""'[]' decodes to an empty tuple."""
|
| 678 |
from free_claude_code.config.settings import Settings
|
|
|
|
| 673 |
),
|
| 674 |
)
|
| 675 |
|
| 676 |
+
def test_instances_parse_applied_models(self, monkeypatch):
|
| 677 |
+
"""Applied model ids persist per instance and default to empty."""
|
| 678 |
+
from free_claude_code.config.settings import (
|
| 679 |
+
OpenAICompatibleInstance,
|
| 680 |
+
Settings,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
monkeypatch.setenv(
|
| 684 |
+
"OPENAI_COMPATIBLE_INSTANCES",
|
| 685 |
+
'[{"base_url": "https://a.example/v1", "models": ["gpt-4o", '
|
| 686 |
+
'"deepseek-v3"]}, {"base_url": "https://b.example"}]',
|
| 687 |
+
)
|
| 688 |
+
monkeypatch.setitem(Settings.model_config, "env_file", ())
|
| 689 |
+
|
| 690 |
+
instances = Settings().openai_compatible_instances
|
| 691 |
+
|
| 692 |
+
assert instances == (
|
| 693 |
+
OpenAICompatibleInstance(
|
| 694 |
+
base_url="https://a.example/v1",
|
| 695 |
+
models=("gpt-4o", "deepseek-v3"),
|
| 696 |
+
),
|
| 697 |
+
OpenAICompatibleInstance(base_url="https://b.example"),
|
| 698 |
+
)
|
| 699 |
+
assert instances[1].models == ()
|
| 700 |
+
|
| 701 |
def test_instances_empty_json_array(self, monkeypatch):
|
| 702 |
"""'[]' decodes to an empty tuple."""
|
| 703 |
from free_claude_code.config.settings import Settings
|
|
@@ -259,7 +259,8 @@ def test_openai_compatible_instance_status_entries_numbered() -> None:
|
|
| 259 |
state = {
|
| 260 |
"OPENAI_COMPATIBLE_INSTANCES": {
|
| 261 |
"value": (
|
| 262 |
-
'[{"base_url": "https://a.example/v1", "api_keys": "k1"
|
|
|
|
| 263 |
'{"base_url": ""}]'
|
| 264 |
)
|
| 265 |
},
|
|
@@ -278,6 +279,8 @@ def test_openai_compatible_instance_status_entries_numbered() -> None:
|
|
| 278 |
assert by_provider["openai_compatible_1"]["display_name"] == (
|
| 279 |
"OpenAI-Compatible Endpoint #1"
|
| 280 |
)
|
|
|
|
| 281 |
|
| 282 |
assert by_provider["openai_compatible_2"]["status"] == "missing_config"
|
| 283 |
assert by_provider["openai_compatible_2"]["label"] == "Missing base URL"
|
|
|
|
|
|
| 259 |
state = {
|
| 260 |
"OPENAI_COMPATIBLE_INSTANCES": {
|
| 261 |
"value": (
|
| 262 |
+
'[{"base_url": "https://a.example/v1", "api_keys": "k1", '
|
| 263 |
+
'"models": ["gpt-4o"]}, '
|
| 264 |
'{"base_url": ""}]'
|
| 265 |
)
|
| 266 |
},
|
|
|
|
| 279 |
assert by_provider["openai_compatible_1"]["display_name"] == (
|
| 280 |
"OpenAI-Compatible Endpoint #1"
|
| 281 |
)
|
| 282 |
+
assert by_provider["openai_compatible_1"]["models"] == ["gpt-4o"]
|
| 283 |
|
| 284 |
assert by_provider["openai_compatible_2"]["status"] == "missing_config"
|
| 285 |
assert by_provider["openai_compatible_2"]["label"] == "Missing base URL"
|
| 286 |
+
assert by_provider["openai_compatible_2"]["models"] == []
|
|
@@ -620,7 +620,7 @@ wheels = [
|
|
| 620 |
|
| 621 |
[[package]]
|
| 622 |
name = "free-claude-code"
|
| 623 |
-
version = "4.
|
| 624 |
source = { editable = "." }
|
| 625 |
dependencies = [
|
| 626 |
{ name = "aiohttp" },
|
|
|
|
| 620 |
|
| 621 |
[[package]]
|
| 622 |
name = "free-claude-code"
|
| 623 |
+
version = "4.25.0"
|
| 624 |
source = { editable = "." }
|
| 625 |
dependencies = [
|
| 626 |
{ name = "aiohttp" },
|