mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 14:14:47 +00:00
feat(i18n): Added support for Chinese language pack
This commit is contained in:
1 parent
bd389fda44
commit
7ffb8a4234
84 files changed
+4122
-1281
No files matched your search
+12
@@ -28,12 +28,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^25.8.13",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
@@ -761,10 +763,14 @@
|
|||||||
|
|
||||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||||
|
|
||||||
|
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"i18next": ["i18next@25.8.13", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
@@ -931,6 +937,8 @@
|
|||||||
|
|
||||||
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
|
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
|
||||||
|
|
||||||
|
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
|
||||||
|
|
||||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||||
@@ -1069,12 +1077,16 @@
|
|||||||
|
|
||||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||||
|
|
||||||
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
|
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
|
||||||
|
|
||||||
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
|
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
|
||||||
|
|
||||||
|
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||||
|
|
||||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="],
|
"webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="],
|
||||||
|
|||||||
@@ -42,12 +42,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^25.8.13",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { queryClient } from '@/lib/query-client';
|
import { queryClient } from '@/lib/query-client';
|
||||||
|
import '@/lib/i18n';
|
||||||
import { ThemeProvider } from '@/components/layout/theme-provider';
|
import { ThemeProvider } from '@/components/layout/theme-provider';
|
||||||
import { PrivacyProvider } from '@/contexts/privacy-context';
|
import { PrivacyProvider } from '@/contexts/privacy-context';
|
||||||
import { AuthProvider } from '@/contexts/auth-context';
|
import { AuthProvider } from '@/contexts/auth-context';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||||
|
|
||||||
interface AccountSafetyWarningCardProps {
|
interface AccountSafetyWarningCardProps {
|
||||||
@@ -24,21 +25,14 @@ export function AccountSafetyWarningCard({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
showProxySettingsLink = false,
|
showProxySettingsLink = false,
|
||||||
}: AccountSafetyWarningCardProps) {
|
}: AccountSafetyWarningCardProps) {
|
||||||
const title = 'OAuth Account Safety Warning';
|
const { t } = useTranslation();
|
||||||
const subtitle = 'Issue #509 · Gemini + AGY OAuth risk';
|
const title = t('accountSafetyWarning.title');
|
||||||
const firstLine = (
|
const subtitle = t('accountSafetyWarning.subtitle');
|
||||||
<>
|
const firstLine = t('accountSafetyWarning.firstLine');
|
||||||
Issue #509 documents suspension/ban reports tied to <code className="font-mono">ccs agy</code>{' '}
|
const secondLine = t('accountSafetyWarning.secondLine');
|
||||||
and shared-account usage between <code className="font-mono">ccs gemini</code> and{' '}
|
|
||||||
<code className="font-mono">ccs agy</code>.
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const secondLine = (
|
|
||||||
<>Continue only if you accept full responsibility for OAuth and account-access risk.</>
|
|
||||||
);
|
|
||||||
const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509';
|
const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509';
|
||||||
const issueLabel = 'Read issue #509';
|
const issueLabel = t('accountSafetyWarning.issueLabel');
|
||||||
const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy';
|
const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
|
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
||||||
import {
|
import {
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
@@ -40,6 +41,7 @@ interface AccountsTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const setDefaultMutation = useSetDefaultAccount();
|
const setDefaultMutation = useSetDefaultAccount();
|
||||||
const deleteMutation = useDeleteAccount();
|
const deleteMutation = useDeleteAccount();
|
||||||
const resetDefaultMutation = useResetDefaultAccount();
|
const resetDefaultMutation = useResetDefaultAccount();
|
||||||
@@ -50,14 +52,14 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
const columns: ColumnDef<Account>[] = [
|
const columns: ColumnDef<Account>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Name',
|
header: t('accountsTable.name'),
|
||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{row.original.name}</span>
|
<span className="font-medium">{row.original.name}</span>
|
||||||
{row.original.name === defaultAccount && (
|
{row.original.name === defaultAccount && (
|
||||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
|
||||||
default
|
{t('accountsTable.defaultBadge')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +67,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: 'Type',
|
header: t('accountsTable.type'),
|
||||||
size: 100,
|
size: 100,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
|
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
|
||||||
@@ -73,7 +75,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created',
|
accessorKey: 'created',
|
||||||
header: 'Created',
|
header: t('accountsTable.created'),
|
||||||
size: 150,
|
size: 150,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.created);
|
const date = new Date(row.original.created);
|
||||||
@@ -82,7 +84,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_used',
|
accessorKey: 'last_used',
|
||||||
header: 'Last Used',
|
header: t('accountsTable.lastUsed'),
|
||||||
size: 150,
|
size: 150,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>;
|
if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>;
|
||||||
@@ -92,7 +94,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'context',
|
id: 'context',
|
||||||
header: 'History Sync',
|
header: t('accountsTable.historySync'),
|
||||||
size: 170,
|
size: 170,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (row.original.type === 'cliproxy') {
|
if (row.original.type === 'cliproxy') {
|
||||||
@@ -103,32 +105,42 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
if (mode === 'shared') {
|
if (mode === 'shared') {
|
||||||
const group = row.original.context_group || 'default';
|
const group = row.original.context_group || 'default';
|
||||||
if (row.original.continuity_mode === 'deeper') {
|
if (row.original.continuity_mode === 'deeper') {
|
||||||
return <span className="text-muted-foreground">shared ({group}, deeper)</span>;
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('accountsTable.sharedGroupDeeper', { group })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.original.continuity_inferred) {
|
if (row.original.continuity_inferred) {
|
||||||
return (
|
return (
|
||||||
<span className="text-amber-700 dark:text-amber-400">
|
<span className="text-amber-700 dark:text-amber-400">
|
||||||
shared ({group}, standard legacy)
|
{t('accountsTable.sharedGroupLegacy', { group })}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('accountsTable.sharedGroupStandard', { group })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.original.context_inferred) {
|
if (row.original.context_inferred) {
|
||||||
return (
|
return (
|
||||||
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span>
|
<span className="text-amber-700 dark:text-amber-400">
|
||||||
|
{t('accountsTable.isolatedLegacy')}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="text-muted-foreground">isolated</span>;
|
return <span className="text-muted-foreground">{t('accountsTable.isolated')}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: t('accountsTable.actions'),
|
||||||
size: 220,
|
size: 220,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isDefault = row.original.name === defaultAccount;
|
const isDefault = row.original.name === defaultAccount;
|
||||||
@@ -149,10 +161,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
className="h-8 px-2"
|
className="h-8 px-2"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => setContextTarget(row.original)}
|
onClick={() => setContextTarget(row.original)}
|
||||||
title="Edit sync mode, group, and continuity depth"
|
title={t('accountsTable.syncTitle')}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||||
Sync
|
{t('accountsTable.sync')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isCliproxy && hasLegacyInference && (
|
{!isCliproxy && hasLegacyInference && (
|
||||||
@@ -177,10 +189,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
title="Confirm this legacy account's current mode as explicit"
|
title={t('accountsTable.confirmLegacyTitle')}
|
||||||
>
|
>
|
||||||
<CheckCheck className="w-3 h-3 mr-1" />
|
<CheckCheck className="w-3 h-3 mr-1" />
|
||||||
Confirm
|
{t('accountsTable.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -191,7 +203,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
||||||
>
|
>
|
||||||
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
|
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
|
||||||
{isDefault ? 'Active' : 'Set Default'}
|
{isDefault ? t('accountsTable.active') : t('accountsTable.setDefault')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -199,7 +211,11 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
disabled={isDefault || isPending}
|
disabled={isDefault || isPending}
|
||||||
onClick={() => setDeleteTarget(row.original.name)}
|
onClick={() => setDeleteTarget(row.original.name)}
|
||||||
title={isDefault ? 'Cannot delete default account' : 'Delete account'}
|
title={
|
||||||
|
isDefault
|
||||||
|
? t('accountsTable.cannotDeleteDefault')
|
||||||
|
: t('accountsTable.deleteAccount')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -218,10 +234,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">{t('accountsTable.noAccounts')}</div>
|
||||||
No CCS auth accounts found. Use{' '}
|
|
||||||
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +292,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
disabled={resetDefaultMutation.isPending}
|
disabled={resetDefaultMutation.isPending}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
Reset to CCS Default
|
{t('accountsTable.resetToDefault')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -293,14 +306,13 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Account</AlertDialogTitle>
|
<AlertDialogTitle>{t('accountsTable.deleteDialogTitle')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the account "{deleteTarget}"? This will
|
{t('accountsTable.deleteDialogDesc', { name: deleteTarget ?? '' })}
|
||||||
remove the profile and all its session data. This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t('accountsTable.cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -310,7 +322,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
{t('accountsTable.delete')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
KIRO_AUTH_METHOD_OPTIONS,
|
KIRO_AUTH_METHOD_OPTIONS,
|
||||||
} from '@/lib/provider-config';
|
} from '@/lib/provider-config';
|
||||||
import type { KiroAuthMethod } from '@/lib/provider-config';
|
import type { KiroAuthMethod } from '@/lib/provider-config';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface AddAccountDialogProps {
|
interface AddAccountDialogProps {
|
||||||
@@ -76,6 +77,7 @@ export function AddAccountDialog({
|
|||||||
const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false);
|
const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false);
|
||||||
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false);
|
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false);
|
||||||
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
|
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
|
||||||
|
const { t } = useTranslation();
|
||||||
const wasAuthenticatingRef = useRef(false);
|
const wasAuthenticatingRef = useRef(false);
|
||||||
const authFlow = useCliproxyAuthFlow();
|
const authFlow = useCliproxyAuthFlow();
|
||||||
const kiroImportMutation = useKiroImport();
|
const kiroImportMutation = useKiroImport();
|
||||||
@@ -318,13 +320,13 @@ export function AddAccountDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isKiro
|
{isKiro
|
||||||
? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.'
|
? t('addAccountDialog.descKiro')
|
||||||
: isDeviceCode
|
: isDeviceCode
|
||||||
? 'Click Authenticate. A verification code will appear for you to enter on the provider website.'
|
? t('addAccountDialog.descDeviceCode')
|
||||||
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
|
: t('addAccountDialog.descOauth')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -344,10 +346,9 @@ export function AddAccountDialog({
|
|||||||
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
|
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
|
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
|
||||||
<ShieldAlert className="h-3.5 w-3.5" />
|
<ShieldAlert className="h-3.5 w-3.5" />
|
||||||
Power user mode enabled
|
{t('addAccountDialog.powerUserEnabled')}
|
||||||
</div>
|
</div>
|
||||||
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full
|
{t('addAccountDialog.powerUserSkipped')}
|
||||||
responsibility for OAuth/account risk.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ export function AddAccountDialog({
|
|||||||
{/* Kiro auth method */}
|
{/* Kiro auth method */}
|
||||||
{isKiro && !showAuthUI && (
|
{isKiro && !showAuthUI && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="kiro-auth-method">Auth Method</Label>
|
<Label htmlFor="kiro-auth-method">{t('addAccountDialog.authMethod')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={kiroAuthMethod}
|
value={kiroAuthMethod}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -376,7 +377,7 @@ export function AddAccountDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="kiro-auth-method">
|
<SelectTrigger id="kiro-auth-method">
|
||||||
<SelectValue placeholder="Select Kiro auth method" />
|
<SelectValue placeholder={t('addAccountDialog.selectKiroAuthMethod')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
|
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
|
||||||
@@ -394,7 +395,9 @@ export function AddAccountDialog({
|
|||||||
{!showAuthUI && (
|
{!showAuthUI && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nickname">
|
<Label htmlFor="nickname">
|
||||||
{requiresNickname ? 'Nickname (required)' : 'Nickname (optional)'}
|
{requiresNickname
|
||||||
|
? t('addAccountDialog.nicknameRequired')
|
||||||
|
: t('addAccountDialog.nicknameOptional')}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -405,15 +408,15 @@ export function AddAccountDialog({
|
|||||||
setNickname(e.target.value);
|
setNickname(e.target.value);
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., work, personal"
|
placeholder={t('addAccountDialog.nicknamePlaceholder')}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{requiresNickname
|
{requiresNickname
|
||||||
? 'Required for this provider. Use a unique friendly name (e.g., work, personal).'
|
? t('addAccountDialog.nicknameRequiredHint')
|
||||||
: 'A friendly name to identify this account. Auto-generated from email if left empty.'}
|
: t('addAccountDialog.nicknameOptionalHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -425,12 +428,12 @@ export function AddAccountDialog({
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
||||||
Waiting for authentication...
|
{t('addAccountDialog.waitingForAuth')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{authFlow.isDeviceCodeFlow
|
{authFlow.isDeviceCodeFlow
|
||||||
? 'A verification code dialog will appear shortly. Enter the code on the provider website.'
|
? t('addAccountDialog.deviceCodeHint')
|
||||||
: 'Complete the authentication in your browser. This dialog closes automatically.'}
|
: t('addAccountDialog.browserHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -438,7 +441,7 @@ export function AddAccountDialog({
|
|||||||
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
{authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">Open this URL in any browser to sign in:</Label>
|
<Label className="text-xs">{t('addAccountDialog.openUrlLabel')}</Label>
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
|
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
|
||||||
{authFlow.authUrl}
|
{authFlow.authUrl}
|
||||||
@@ -448,12 +451,12 @@ export function AddAccountDialog({
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3 mr-1" />
|
<Check className="w-3 h-3 mr-1" />
|
||||||
Copied
|
{t('addAccountDialog.copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="w-3 h-3 mr-1" />
|
<Copy className="w-3 h-3 mr-1" />
|
||||||
Copy
|
{t('addAccountDialog.copy')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -464,15 +467,12 @@ export function AddAccountDialog({
|
|||||||
if (!authFlow.authUrl) return;
|
if (!authFlow.authUrl) return;
|
||||||
const popup = window.open(authFlow.authUrl, '_blank');
|
const popup = window.open(authFlow.authUrl, '_blank');
|
||||||
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
|
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
|
||||||
toast.warning(
|
toast.warning(t('addAccountDialog.popupBlocked'), { duration: 5000 });
|
||||||
'Popup blocked. Copy the URL above and open it manually in your browser.',
|
|
||||||
{ duration: 5000 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3 mr-1" />
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
Open
|
{t('addAccountDialog.open')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -481,13 +481,13 @@ export function AddAccountDialog({
|
|||||||
{/* Callback paste field */}
|
{/* Callback paste field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="callback-url" className="text-xs">
|
<Label htmlFor="callback-url" className="text-xs">
|
||||||
Redirect didn't work? Paste the callback URL:
|
{t('addAccountDialog.redirectPasteLabel')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="callback-url"
|
id="callback-url"
|
||||||
value={callbackUrl}
|
value={callbackUrl}
|
||||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||||
placeholder="Paste the redirect URL here..."
|
placeholder={t('addAccountDialog.callbackPlaceholder')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -499,10 +499,10 @@ export function AddAccountDialog({
|
|||||||
{authFlow.isSubmittingCallback ? (
|
{authFlow.isSubmittingCallback ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||||
Submitting...
|
{t('addAccountDialog.submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Submit Callback'
|
t('addAccountDialog.submitCallback')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -511,7 +511,7 @@ export function AddAccountDialog({
|
|||||||
|
|
||||||
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
Preparing sign-in URL...
|
{t('addAccountDialog.preparingUrl')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -524,26 +524,26 @@ export function AddAccountDialog({
|
|||||||
{kiroImportMutation.isPending && (
|
{kiroImportMutation.isPending && (
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
||||||
Importing token from Kiro IDE...
|
{t('addAccountDialog.importingToken')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onClick={handleCancel}>
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
Cancel
|
{t('addAccountDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
{isKiro && !showAuthUI && (
|
{isKiro && !showAuthUI && (
|
||||||
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
|
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
|
||||||
{kiroImportMutation.isPending ? (
|
{kiroImportMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Importing...
|
{t('addAccountDialog.importing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Import from IDE
|
{t('addAccountDialog.importFromIde')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -560,7 +560,7 @@ export function AddAccountDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
Authenticate
|
{t('addAccountDialog.authenticate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||||
import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants';
|
import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export function AntigravityResponsibilityChecklist({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: AntigravityResponsibilityChecklistProps) {
|
}: AntigravityResponsibilityChecklistProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const completedSteps = [
|
const completedSteps = [
|
||||||
value.reviewedIssue509,
|
value.reviewedIssue509,
|
||||||
value.understandsBanRisk,
|
value.understandsBanRisk,
|
||||||
@@ -50,21 +52,19 @@ export function AntigravityResponsibilityChecklist({
|
|||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p>
|
<p className="text-sm font-semibold leading-5">{t('antigravityChecklist.title')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('antigravityChecklist.subtitle')}</p>
|
||||||
Complete all 4 steps before you can authenticate.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
|
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
|
||||||
Mandatory
|
{t('antigravityChecklist.mandatory')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>Completion</span>
|
<span>{t('antigravityChecklist.completion')}</span>
|
||||||
<span>{completedSteps}/4 steps</span>
|
<span>{t('antigravityChecklist.stepsCount', { current: completedSteps })}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progressValue} className="h-2" />
|
<Progress value={progressValue} className="h-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Copy, Check, Terminal } from 'lucide-react';
|
import { Copy, Check, Terminal } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface CreateAuthProfileDialogProps {
|
interface CreateAuthProfileDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,6 +26,7 @@ interface CreateAuthProfileDialogProps {
|
|||||||
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||||
|
|
||||||
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
|
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [profileName, setProfileName] = useState('');
|
const [profileName, setProfileName] = useState('');
|
||||||
const [shareContext, setShareContext] = useState(false);
|
const [shareContext, setShareContext] = useState(false);
|
||||||
const [contextGroup, setContextGroup] = useState('');
|
const [contextGroup, setContextGroup] = useState('');
|
||||||
@@ -52,7 +54,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
: 'ccs auth create <name>';
|
: t('createAuthProfileDialog.commandFallback');
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (!isValidName || (shareContext && !isValidContextGroup)) return;
|
if (!isValidName || (shareContext && !isValidContextGroup)) return;
|
||||||
@@ -74,27 +76,23 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New Account</DialogTitle>
|
<DialogTitle>{t('createAuthProfileDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('createAuthProfileDialog.description')}</DialogDescription>
|
||||||
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
|
|
||||||
edit sync mode, group, and continuity depth later from the Accounts table.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="profile-name">Profile Name</Label>
|
<Label htmlFor="profile-name">{t('createAuthProfileDialog.profileName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-name"
|
id="profile-name"
|
||||||
value={profileName}
|
value={profileName}
|
||||||
onChange={(e) => setProfileName(e.target.value)}
|
onChange={(e) => setProfileName(e.target.value)}
|
||||||
placeholder="e.g., work, personal, client"
|
placeholder={t('createAuthProfileDialog.profileNamePlaceholder')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{profileName && !isValidName && (
|
{profileName && !isValidName && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
Name must start with a letter and contain only letters, numbers, dashes, or
|
{t('createAuthProfileDialog.invalidProfileName')}
|
||||||
underscores.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,22 +105,24 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
onCheckedChange={(checked) => setShareContext(checked === true)}
|
onCheckedChange={(checked) => setShareContext(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="share-context" className="cursor-pointer">
|
<Label htmlFor="share-context" className="cursor-pointer">
|
||||||
Enable shared history sync with other ccs auth accounts
|
{t('createAuthProfileDialog.enableSharedHistory')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareContext && (
|
{shareContext && (
|
||||||
<div className="space-y-2 pl-6">
|
<div className="space-y-2 pl-6">
|
||||||
<Label htmlFor="context-group">History Sync Group (optional)</Label>
|
<Label htmlFor="context-group">
|
||||||
|
{t('createAuthProfileDialog.historySyncGroupOptional')}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="context-group"
|
id="context-group"
|
||||||
value={contextGroup}
|
value={contextGroup}
|
||||||
onChange={(e) => setContextGroup(e.target.value)}
|
onChange={(e) => setContextGroup(e.target.value)}
|
||||||
placeholder="default, sprint-a, client-x"
|
placeholder={t('createAuthProfileDialog.historySyncGroupPlaceholder')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Leave empty to use the default shared group. Spaces are normalized to dashes.
|
{t('createAuthProfileDialog.historySyncGroupHint')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -131,17 +131,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
|
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="deeper-continuity" className="cursor-pointer">
|
<Label htmlFor="deeper-continuity" className="cursor-pointer">
|
||||||
Advanced: deeper continuity mode
|
{t('createAuthProfileDialog.deeperContinuity')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
|
{t('createAuthProfileDialog.deeperContinuityHint')}
|
||||||
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
|
|
||||||
</p>
|
</p>
|
||||||
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
Group must start with a letter and use only letters, numbers, dashes, or
|
{t('createAuthProfileDialog.invalidContextGroup', {
|
||||||
underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars).
|
max: MAX_CONTEXT_GROUP_LENGTH,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +149,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Command</Label>
|
<Label>{t('createAuthProfileDialog.command')}</Label>
|
||||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
|
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
|
||||||
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
|
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
<code className="flex-1 break-all">{command}</code>
|
<code className="flex-1 break-all">{command}</code>
|
||||||
@@ -170,20 +170,17 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>After running the command:</p>
|
<p>{t('createAuthProfileDialog.afterRunPrefix')}</p>
|
||||||
<ol className="list-decimal list-inside pl-2 space-y-0.5">
|
<ol className="list-decimal list-inside pl-2 space-y-0.5">
|
||||||
<li>Complete the Claude login in your browser</li>
|
<li>{t('createAuthProfileDialog.afterRunStep1')}</li>
|
||||||
<li>Return here and refresh to see the new account</li>
|
<li>{t('createAuthProfileDialog.afterRunStep2')}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="pt-1">
|
<p className="pt-1">{t('createAuthProfileDialog.poolingHint')}</p>
|
||||||
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
|
|
||||||
page action button.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onClick={handleClose}>
|
<Button variant="ghost" onClick={handleClose}>
|
||||||
Close
|
{t('createAuthProfileDialog.close')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
@@ -192,12 +189,12 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
Copied!
|
{t('createAuthProfileDialog.copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
Copy Command
|
{t('createAuthProfileDialog.copyCommand')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Account } from '@/lib/api-client';
|
import type { Account } from '@/lib/api-client';
|
||||||
import { useUpdateAccountContext } from '@/hooks/use-accounts';
|
import { useUpdateAccountContext } from '@/hooks/use-accounts';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface EditAccountContextDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
|
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const updateContextMutation = useUpdateAccountContext();
|
const updateContextMutation = useUpdateAccountContext();
|
||||||
const [mode, setMode] = useState<ContextMode>(
|
const [mode, setMode] = useState<ContextMode>(
|
||||||
account.context_mode === 'shared' ? 'shared' : 'isolated'
|
account.context_mode === 'shared' ? 'shared' : 'isolated'
|
||||||
@@ -78,89 +80,86 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
|||||||
<Dialog open onOpenChange={handleOpenChange}>
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit History Sync</DialogTitle>
|
<DialogTitle>{t('editAccountContext.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Configure how "{account.name}" shares history and continuity with other
|
{t('editAccountContext.description', { name: account.name })}
|
||||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
|
||||||
accounts.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="context-mode">Sync Mode</Label>
|
<Label htmlFor="context-mode">{t('editAccountContext.syncMode')}</Label>
|
||||||
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
|
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
|
||||||
<SelectTrigger id="context-mode">
|
<SelectTrigger id="context-mode">
|
||||||
<SelectValue placeholder="Select context mode" />
|
<SelectValue placeholder={t('editAccountContext.selectContextMode')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="isolated">isolated (no sync)</SelectItem>
|
<SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
|
||||||
<SelectItem value="shared">shared (sync enabled)</SelectItem>
|
<SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{mode === 'shared'
|
{mode === 'shared'
|
||||||
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
|
? t('editAccountContext.sharedModeHint')
|
||||||
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
|
: t('editAccountContext.isolatedModeHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'shared' && (
|
{mode === 'shared' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="context-group">History Sync Group</Label>
|
<Label htmlFor="context-group">{t('editAccountContext.historySyncGroup')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="context-group"
|
id="context-group"
|
||||||
value={group}
|
value={group}
|
||||||
onChange={(event) => setGroup(event.target.value)}
|
onChange={(event) => setGroup(event.target.value)}
|
||||||
placeholder="default"
|
placeholder={t('editAccountContext.groupPlaceholder')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
|
{t('editAccountContext.groupHint', { max: MAX_CONTEXT_GROUP_LENGTH })}
|
||||||
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
|
|
||||||
</p>
|
</p>
|
||||||
{!isSharedGroupValid && (
|
{!isSharedGroupValid && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">{t('editAccountContext.invalidGroup')}</p>
|
||||||
Enter a valid group name that starts with a letter.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'shared' && (
|
{mode === 'shared' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="continuity-mode">Continuity Depth</Label>
|
<Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={continuityMode}
|
value={continuityMode}
|
||||||
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
|
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="continuity-mode">
|
<SelectTrigger id="continuity-mode">
|
||||||
<SelectValue placeholder="Select continuity depth" />
|
<SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="standard">standard (projects only)</SelectItem>
|
<SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
|
||||||
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
|
<SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{continuityMode === 'deeper'
|
{continuityMode === 'deeper'
|
||||||
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
|
? t('editAccountContext.deeperHint')
|
||||||
: 'Standard mode syncs project workspace context only.'}
|
: t('editAccountContext.standardHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Credentials and `.anthropic` remain isolated per account in all modes.
|
{t('editAccountContext.credentialsIsolated')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
|
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
|
||||||
Cancel
|
{t('editAccountContext.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
|
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
|
||||||
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
|
{updateContextMutation.isPending
|
||||||
|
? t('editAccountContext.saving')
|
||||||
|
: t('editAccountContext.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy
|
|||||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
|
|
||||||
import type { AccountData, DragOffset } from './types';
|
import type { AccountData, DragOffset } from './types';
|
||||||
@@ -94,6 +95,7 @@ export function AccountCard({
|
|||||||
onPauseToggle,
|
onPauseToggle,
|
||||||
isPausingAccount,
|
isPausingAccount,
|
||||||
}: AccountCardProps) {
|
}: AccountCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const borderSide = BORDER_SIDE_MAP[zone];
|
const borderSide = BORDER_SIDE_MAP[zone];
|
||||||
const borderColor = getBorderColorStyle(zone, account.color);
|
const borderColor = getBorderColorStyle(zone, account.color);
|
||||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||||
@@ -245,7 +247,7 @@ export function AccountCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="text-xs">
|
<TooltipContent side="top" className="text-xs">
|
||||||
{account.paused ? 'Resume account' : 'Pause account'}
|
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -266,7 +268,7 @@ export function AccountCard({
|
|||||||
{quotaLoading ? (
|
{quotaLoading ? (
|
||||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||||
<span>Quota...</span>
|
<span>{t('accountCard.quotaLoading')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : minQuotaValue !== null ? (
|
) : minQuotaValue !== null ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -275,7 +277,7 @@ export function AccountCard({
|
|||||||
<div className="space-y-0.5 cursor-help">
|
<div className="space-y-0.5 cursor-help">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||||
Quota
|
{t('accountCard.quota')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -320,21 +322,23 @@ export function AccountCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : quota?.success ? (
|
) : quota?.success ? (
|
||||||
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
|
<div className="text-[8px] text-muted-foreground/60">
|
||||||
|
{t('accountCard.quotaUnavailable')}
|
||||||
|
</div>
|
||||||
) : quota?.needsReauth ? (
|
) : quota?.needsReauth ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
|
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
|
||||||
<KeyRound className="w-2.5 h-2.5" />
|
<KeyRound className="w-2.5 h-2.5" />
|
||||||
<span>Reauth needed</span>
|
<span>{t('accountCard.reauthNeeded')}</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-[200px]">
|
<TooltipContent side="top" className="max-w-[200px]">
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
{quota.error?.includes('No refresh token')
|
{quota.error?.includes('No refresh token')
|
||||||
? 'Remove and re-add account'
|
? t('accountCard.removeAndReadd')
|
||||||
: quota.error || 'Auto-refresh failed'}
|
: quota.error || t('accountCard.autoRefreshFailed')}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Connection Timeline Component - right sidebar panel
|
* Connection Timeline Component - right sidebar panel
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { STATUS_COLORS } from '@/lib/utils';
|
import { STATUS_COLORS } from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
@@ -16,10 +17,13 @@ interface ConnectionTimelineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
|
export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
|
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
|
||||||
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{t('flowViz.noRecentConnections')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,7 @@ export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelinePr
|
|||||||
<div className="flex items-center gap-2 mb-3 px-2">
|
<div className="flex items-center gap-2 mb-3 px-2">
|
||||||
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
|
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
Connection Timeline
|
{t('flowViz.connectionTimeline')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Flow Visualization Header Component
|
* Flow Visualization Header Component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
|
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export function FlowVizHeader({
|
|||||||
hasCustomPositions,
|
hasCustomPositions,
|
||||||
onResetPositions,
|
onResetPositions,
|
||||||
}: FlowVizHeaderProps) {
|
}: FlowVizHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-3 py-1.5">
|
<div className="flex items-center justify-between px-3 py-1.5">
|
||||||
{onBack ? (
|
{onBack ? (
|
||||||
@@ -28,7 +30,7 @@ export function FlowVizHeader({
|
|||||||
className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50"
|
className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
|
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
|
||||||
<span>Back to providers</span>
|
<span>{t('flowViz.backToProviders')}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
@@ -44,7 +46,7 @@ export function FlowVizHeader({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||||
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span>
|
<span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
|
||||||
</button>
|
</button>
|
||||||
{hasCustomPositions && (
|
{hasCustomPositions && (
|
||||||
<button
|
<button
|
||||||
@@ -52,7 +54,7 @@ export function FlowVizHeader({
|
|||||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
<span>Reset layout</span>
|
<span>{t('flowViz.resetLayout')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Provider Card Component for Flow Visualization
|
* Provider Card Component for Flow Visualization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from 'lucide-react';
|
||||||
@@ -39,6 +40,7 @@ export function ProviderCard({
|
|||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { accounts } = providerData;
|
const { accounts } = providerData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,17 +117,19 @@ export function ProviderCard({
|
|||||||
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
||||||
{providerData.displayName}
|
{providerData.displayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-muted-foreground font-medium uppercase">Provider</p>
|
<p className="text-[10px] text-muted-foreground font-medium uppercase">
|
||||||
|
{t('flowViz.provider')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 relative z-10">
|
<div className="space-y-2 relative z-10">
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="text-muted-foreground">Total Requests</span>
|
<span className="text-muted-foreground">{t('flowViz.totalRequests')}</span>
|
||||||
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
|
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="text-muted-foreground">Accounts</span>
|
<span className="text-muted-foreground">{t('flowViz.accounts')}</span>
|
||||||
<span className="text-foreground font-mono">{accounts.length}</span>
|
<span className="text-foreground font-mono">{accounts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface HistorySyncLearningMapProps {
|
interface HistorySyncLearningMapProps {
|
||||||
@@ -76,6 +77,7 @@ export function HistorySyncLearningMap({
|
|||||||
legacyTargetCount,
|
legacyTargetCount,
|
||||||
cliproxyCount,
|
cliproxyCount,
|
||||||
}: HistorySyncLearningMapProps) {
|
}: HistorySyncLearningMapProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
|
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
|
||||||
|
|
||||||
@@ -84,33 +86,47 @@ export function HistorySyncLearningMap({
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">How History Sync Works</CardTitle>
|
<CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
Isolated -> Shared -> Deeper. Use <code>Sync</code> per row for all changes.
|
{t('historySyncLearningMap.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">Learning Map</Badge>
|
<Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{cliproxyCount > 0 && (
|
{cliproxyCount > 0 && (
|
||||||
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
|
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
|
||||||
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
|
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
|
||||||
managed in Action Center / CLIProxy page.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
|
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
|
||||||
<StageTile title="Isolated" count={isolatedCount} icon={Unlink} tone="isolated" />
|
<StageTile
|
||||||
|
title={t('historySyncLearningMap.isolated')}
|
||||||
|
count={isolatedCount}
|
||||||
|
icon={Unlink}
|
||||||
|
tone="isolated"
|
||||||
|
/>
|
||||||
<div className="hidden sm:flex justify-center text-muted-foreground">
|
<div className="hidden sm:flex justify-center text-muted-foreground">
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<StageTile title="Shared" count={sharedStandardCount} icon={Link2} tone="shared" />
|
<StageTile
|
||||||
|
title={t('historySyncLearningMap.shared')}
|
||||||
|
count={sharedStandardCount}
|
||||||
|
icon={Link2}
|
||||||
|
tone="shared"
|
||||||
|
/>
|
||||||
<div className="hidden sm:flex justify-center text-muted-foreground">
|
<div className="hidden sm:flex justify-center text-muted-foreground">
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<StageTile title="Deeper" count={deeperSharedCount} icon={Waves} tone="deeper" />
|
<StageTile
|
||||||
|
title={t('historySyncLearningMap.deeper')}
|
||||||
|
count={deeperSharedCount}
|
||||||
|
icon={Waves}
|
||||||
|
tone="deeper"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
@@ -119,7 +135,7 @@ export function HistorySyncLearningMap({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span>Show details: groups, switching, and legacy policy</span>
|
<span>{t('historySyncLearningMap.showDetails')}</span>
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
@@ -128,21 +144,20 @@ export function HistorySyncLearningMap({
|
|||||||
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
|
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<p className="font-semibold">Mode Switch</p>
|
<p className="font-semibold">{t('historySyncLearningMap.modeSwitch')}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
Sync dialog lets users move between isolated/shared and choose deeper continuity.
|
{t('historySyncLearningMap.modeSwitchDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
|
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<p className="font-semibold">History Sync Group</p>
|
<p className="font-semibold">{t('historySyncLearningMap.historySyncGroup')}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
Same group means shared project context lane. Default fallback is{' '}
|
{t('historySyncLearningMap.historySyncGroupDesc')}
|
||||||
<code>default</code>.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{groupsToShow.map((group) => (
|
{groupsToShow.map((group) => (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lu
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { UsageSummary } from '@/hooks/use-usage';
|
import type { UsageSummary } from '@/hooks/use-usage';
|
||||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface UsageSummaryCardsProps {
|
interface UsageSummaryCardsProps {
|
||||||
data?: UsageSummary;
|
data?: UsageSummary;
|
||||||
@@ -20,6 +21,7 @@ interface UsageSummaryCardsProps {
|
|||||||
|
|
||||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||||
const { privacyMode } = usePrivacy();
|
const { privacyMode } = usePrivacy();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -48,49 +50,61 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
|||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: 'Total Tokens',
|
title: t('analyticsSummary.totalTokens'),
|
||||||
value: data?.totalTokens ?? 0,
|
value: data?.totalTokens ?? 0,
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
format: (v: number) => formatNumber(v),
|
format: (v: number) => formatNumber(v),
|
||||||
color: 'text-blue-600',
|
color: 'text-blue-600',
|
||||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||||
subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`,
|
subtitle: t('analyticsSummary.totalTokensSubtitle', {
|
||||||
|
input: formatNumber(data?.totalInputTokens ?? 0),
|
||||||
|
output: formatNumber(data?.totalOutputTokens ?? 0),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Cost',
|
title: t('analyticsSummary.totalCost'),
|
||||||
value: data?.totalCost ?? 0,
|
value: data?.totalCost ?? 0,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
format: (v: number) => `$${v.toFixed(2)}`,
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-green-600',
|
color: 'text-green-600',
|
||||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||||
subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`,
|
subtitle: t('analyticsSummary.totalCostSubtitle', {
|
||||||
|
value: data?.averageCostPerDay?.toFixed(2) ?? '0.00',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Cache Tokens',
|
title: t('analyticsSummary.cacheTokens'),
|
||||||
value: data?.totalCacheTokens ?? 0,
|
value: data?.totalCacheTokens ?? 0,
|
||||||
icon: Database,
|
icon: Database,
|
||||||
format: (v: number) => formatNumber(v),
|
format: (v: number) => formatNumber(v),
|
||||||
color: 'text-cyan-600',
|
color: 'text-cyan-600',
|
||||||
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||||
subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`,
|
subtitle: t('analyticsSummary.cacheTokensSubtitle', {
|
||||||
|
cost: cacheCost.toFixed(2),
|
||||||
|
percent: cacheCostPercent,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Input Cost',
|
title: t('analyticsSummary.inputCost'),
|
||||||
value: data?.tokenBreakdown?.input?.cost ?? 0,
|
value: data?.tokenBreakdown?.input?.cost ?? 0,
|
||||||
icon: ArrowDownRight,
|
icon: ArrowDownRight,
|
||||||
format: (v: number) => `$${v.toFixed(2)}`,
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-purple-600',
|
color: 'text-purple-600',
|
||||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||||
subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`,
|
subtitle: t('analyticsSummary.tokensSubtitle', {
|
||||||
|
value: formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Output Cost',
|
title: t('analyticsSummary.outputCost'),
|
||||||
value: data?.tokenBreakdown?.output?.cost ?? 0,
|
value: data?.tokenBreakdown?.output?.cost ?? 0,
|
||||||
icon: ArrowUpRight,
|
icon: ArrowUpRight,
|
||||||
format: (v: number) => `$${v.toFixed(2)}`,
|
format: (v: number) => `$${v.toFixed(2)}`,
|
||||||
color: 'text-orange-600',
|
color: 'text-orange-600',
|
||||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||||
subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`,
|
subtitle: t('analyticsSummary.tokensSubtitle', {
|
||||||
|
value: formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,21 +18,22 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Cpu } from 'lucide-react';
|
import { Cpu } from 'lucide-react';
|
||||||
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** Provider display configuration */
|
/** Provider display configuration */
|
||||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string }> = {
|
const CATEGORY_CONFIG: Record<string, { key: string; color: string }> = {
|
||||||
google: { name: 'Google (Gemini)', color: 'text-blue-600' },
|
google: { key: 'google', color: 'text-blue-600' },
|
||||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600' },
|
openai: { key: 'openai', color: 'text-green-600' },
|
||||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' },
|
anthropic: { key: 'anthropic', color: 'text-orange-600' },
|
||||||
antigravity: { name: 'Antigravity', color: 'text-purple-600' },
|
antigravity: { key: 'antigravity', color: 'text-purple-600' },
|
||||||
other: { name: 'Other', color: 'text-gray-600' },
|
other: { key: 'other', color: 'text-gray-600' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get display name for category */
|
/** Get display name for category */
|
||||||
function getCategoryDisplay(category: string) {
|
function getCategoryDisplay(category: string) {
|
||||||
return (
|
return (
|
||||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
key: 'other',
|
||||||
color: 'text-gray-600',
|
color: 'text-gray-600',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -61,9 +62,12 @@ export function CategorizedModelSelector({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder = 'Select a model',
|
placeholder,
|
||||||
className,
|
className,
|
||||||
}: CategorizedModelSelectorProps) {
|
}: CategorizedModelSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedPlaceholder = placeholder ?? t('categorizedModelSelector.selectModel');
|
||||||
|
|
||||||
// Sort categories by model count (descending)
|
// Sort categories by model count (descending)
|
||||||
const sortedCategories = useMemo(() => {
|
const sortedCategories = useMemo(() => {
|
||||||
if (!modelsData?.byCategory) return [];
|
if (!modelsData?.byCategory) return [];
|
||||||
@@ -84,7 +88,7 @@ export function CategorizedModelSelector({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
|
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
|
||||||
<Cpu className="w-4 h-4" />
|
<Cpu className="w-4 h-4" />
|
||||||
<span>No models available</span>
|
<span>{t('categorizedModelSelector.noModelsAvailable')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,7 +96,7 @@ export function CategorizedModelSelector({
|
|||||||
return (
|
return (
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||||
<SelectTrigger className={cn('w-[320px]', className)}>
|
<SelectTrigger className={cn('w-[320px]', className)}>
|
||||||
<SelectValue placeholder={placeholder}>
|
<SelectValue placeholder={resolvedPlaceholder}>
|
||||||
{value && (
|
{value && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate">{value}</span>
|
<span className="truncate">{value}</span>
|
||||||
@@ -104,7 +108,9 @@ export function CategorizedModelSelector({
|
|||||||
{sortedCategories.map(({ category, display, models }) => (
|
{sortedCategories.map(({ category, display, models }) => (
|
||||||
<SelectGroup key={category}>
|
<SelectGroup key={category}>
|
||||||
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
|
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
|
||||||
<span className={cn('font-semibold', display.color)}>{display.name}</span>
|
<span className={cn('font-semibold', display.color)}>
|
||||||
|
{t(`cliproxyModelCategory.${display.key}`)}
|
||||||
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
||||||
{models.length}
|
{models.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -129,6 +135,8 @@ export function CategorizedModelSelectorCompact({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
|
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategorizedModelSelector
|
<CategorizedModelSelector
|
||||||
modelsData={modelsData}
|
modelsData={modelsData}
|
||||||
@@ -136,7 +144,7 @@ export function CategorizedModelSelectorCompact({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="Model..."
|
placeholder={t('categorizedModelSelector.modelPlaceholder')}
|
||||||
className="w-[200px]"
|
className="w-[200px]"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||||
@@ -69,6 +70,7 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const createMutation = useCreateVariant();
|
const createMutation = useCreateVariant();
|
||||||
const { data: authData } = useCliproxyAuth();
|
const { data: authData } = useCliproxyAuth();
|
||||||
const { privacyMode } = usePrivacy();
|
const { privacyMode } = usePrivacy();
|
||||||
@@ -151,8 +153,12 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
{mode === 'single' ? (
|
{mode === 'single' ? (
|
||||||
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{t('cliproxyDialog.name')}</Label>
|
||||||
<Input id="name" {...singleForm.register('name')} placeholder="my-gemini" />
|
<Input
|
||||||
|
id="name"
|
||||||
|
{...singleForm.register('name')}
|
||||||
|
placeholder={t('cliproxyDialog.placeholderName')}
|
||||||
|
/>
|
||||||
{singleForm.formState.errors.name && (
|
{singleForm.formState.errors.name && (
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
{singleForm.formState.errors.name.message}
|
{singleForm.formState.errors.name.message}
|
||||||
@@ -161,13 +167,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="provider">Provider</Label>
|
<Label htmlFor="provider">{t('cliproxyDialog.provider')}</Label>
|
||||||
<select
|
<select
|
||||||
id="provider"
|
id="provider"
|
||||||
{...singleForm.register('provider')}
|
{...singleForm.register('provider')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">Select provider...</option>
|
<option value="">{t('cliproxyDialog.selectProvider')}</option>
|
||||||
{providerOptions.map((opt) => (
|
{providerOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -183,17 +189,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
|
|
||||||
{selectedProvider && providerAccounts.length > 0 && (
|
{selectedProvider && providerAccounts.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="account">Account</Label>
|
<Label htmlFor="account">{t('cliproxyDialog.account')}</Label>
|
||||||
<select
|
<select
|
||||||
id="account"
|
id="account"
|
||||||
{...singleForm.register('account')}
|
{...singleForm.register('account')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">Use default account</option>
|
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||||
{providerAccounts.map((acc) => (
|
{providerAccounts.map((acc) => (
|
||||||
<option key={acc.id} value={acc.id}>
|
<option key={acc.id} value={acc.id}>
|
||||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||||
{acc.isDefault ? ' (default)' : ''}
|
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -201,39 +207,45 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="model">Model (optional)</Label>
|
<Label htmlFor="model">{t('cliproxyDialog.modelOptional')}</Label>
|
||||||
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" />
|
<Input
|
||||||
|
id="model"
|
||||||
|
{...singleForm.register('model')}
|
||||||
|
placeholder={t('cliproxyDialog.placeholderModel')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="target">Default Target</Label>
|
<Label htmlFor="target">{t('cliproxyDialog.defaultTarget')}</Label>
|
||||||
<select
|
<select
|
||||||
id="target"
|
id="target"
|
||||||
{...singleForm.register('target')}
|
{...singleForm.register('target')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="claude">Claude Code</option>
|
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
|
||||||
<option value="droid">Factory Droid</option>
|
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{t('cliproxyDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
{createMutation.isPending
|
||||||
|
? t('cliproxyDialog.creating')
|
||||||
|
: t('cliproxyDialog.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="comp-name">Name</Label>
|
<Label htmlFor="comp-name">{t('cliproxyDialog.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="comp-name"
|
id="comp-name"
|
||||||
{...compositeForm.register('name')}
|
{...compositeForm.register('name')}
|
||||||
placeholder="my-composite"
|
placeholder={t('cliproxyDialog.placeholderComposite')}
|
||||||
/>
|
/>
|
||||||
{compositeForm.formState.errors.name && (
|
{compositeForm.formState.errors.name && (
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
@@ -243,17 +255,17 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Tier Configuration</Label>
|
<Label>{t('cliproxyDialog.tierConfig')}</Label>
|
||||||
<Tabs defaultValue="opus" className="w-full">
|
<Tabs defaultValue="opus" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="opus">Opus</TabsTrigger>
|
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
|
||||||
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
|
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
|
||||||
<TabsTrigger value="haiku">Haiku</TabsTrigger>
|
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
||||||
<TabsContent key={tier} value={tier} className="space-y-3">
|
<TabsContent key={tier} value={tier} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`${tier}-provider`}>Provider</Label>
|
<Label htmlFor={`${tier}-provider`}>{t('cliproxyDialog.provider')}</Label>
|
||||||
<select
|
<select
|
||||||
id={`${tier}-provider`}
|
id={`${tier}-provider`}
|
||||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||||
@@ -267,11 +279,11 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`${tier}-model`}>Model</Label>
|
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${tier}-model`}
|
id={`${tier}-model`}
|
||||||
{...compositeForm.register(`tiers.${tier}.model`)}
|
{...compositeForm.register(`tiers.${tier}.model`)}
|
||||||
placeholder="model-id"
|
placeholder={t('cliproxyDialog.placeholderModelId')}
|
||||||
/>
|
/>
|
||||||
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
@@ -280,11 +292,13 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`${tier}-account`}>Account (optional)</Label>
|
<Label htmlFor={`${tier}-account`}>
|
||||||
|
{t('cliproxyDialog.accountOptional')}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${tier}-account`}
|
id={`${tier}-account`}
|
||||||
{...compositeForm.register(`tiers.${tier}.account`)}
|
{...compositeForm.register(`tiers.${tier}.account`)}
|
||||||
placeholder="account-id"
|
placeholder={t('cliproxyDialog.placeholderAccountId')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -293,36 +307,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="default-tier">Default Tier</Label>
|
<Label htmlFor="default-tier">{t('cliproxyDialog.defaultTier')}</Label>
|
||||||
<select
|
<select
|
||||||
id="default-tier"
|
id="default-tier"
|
||||||
{...compositeForm.register('default_tier')}
|
{...compositeForm.register('default_tier')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="opus">Opus</option>
|
<option value="opus">{t('cliproxyDialog.opus')}</option>
|
||||||
<option value="sonnet">Sonnet</option>
|
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
|
||||||
<option value="haiku">Haiku</option>
|
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="composite-target">Default Target</Label>
|
<Label htmlFor="composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
|
||||||
<select
|
<select
|
||||||
id="composite-target"
|
id="composite-target"
|
||||||
{...compositeForm.register('target')}
|
{...compositeForm.register('target')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="claude">Claude Code</option>
|
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
|
||||||
<option value="droid">Factory Droid</option>
|
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{t('cliproxyDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
{createMutation.isPending
|
||||||
|
? t('cliproxyDialog.creating')
|
||||||
|
: t('cliproxyDialog.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||||
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
||||||
@@ -117,6 +118,7 @@ function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFor
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) {
|
export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const updateMutation = useUpdateVariant();
|
const updateMutation = useUpdateVariant();
|
||||||
const isComposite = variant?.type === 'composite';
|
const isComposite = variant?.type === 'composite';
|
||||||
|
|
||||||
@@ -264,24 +266,28 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name}
|
{isComposite
|
||||||
|
? t('cliproxyDialog.editCompositeTitle', { name: variant.name })
|
||||||
|
: t('cliproxyDialog.editSingleTitle', { name: variant.name })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isComposite ? (
|
{isComposite ? (
|
||||||
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Tier Configuration</Label>
|
<Label>{t('cliproxyDialog.tierConfig')}</Label>
|
||||||
<Tabs defaultValue="opus" className="w-full">
|
<Tabs defaultValue="opus" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="opus">Opus</TabsTrigger>
|
<TabsTrigger value="opus">{t('cliproxyDialog.opus')}</TabsTrigger>
|
||||||
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
|
<TabsTrigger value="sonnet">{t('cliproxyDialog.sonnet')}</TabsTrigger>
|
||||||
<TabsTrigger value="haiku">Haiku</TabsTrigger>
|
<TabsTrigger value="haiku">{t('cliproxyDialog.haiku')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
||||||
<TabsContent key={tier} value={tier} className="space-y-3">
|
<TabsContent key={tier} value={tier} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`edit-${tier}-provider`}>Provider</Label>
|
<Label htmlFor={`edit-${tier}-provider`}>
|
||||||
|
{t('cliproxyDialog.provider')}
|
||||||
|
</Label>
|
||||||
<select
|
<select
|
||||||
id={`edit-${tier}-provider`}
|
id={`edit-${tier}-provider`}
|
||||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||||
@@ -295,11 +301,11 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`edit-${tier}-model`}>Model</Label>
|
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`edit-${tier}-model`}
|
id={`edit-${tier}-model`}
|
||||||
{...compositeForm.register(`tiers.${tier}.model`)}
|
{...compositeForm.register(`tiers.${tier}.model`)}
|
||||||
placeholder="model-id"
|
placeholder={t('cliproxyDialog.placeholderModelId')}
|
||||||
/>
|
/>
|
||||||
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
@@ -308,11 +314,13 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`edit-${tier}-account`}>Account (optional)</Label>
|
<Label htmlFor={`edit-${tier}-account`}>
|
||||||
|
{t('cliproxyDialog.accountOptional')}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`edit-${tier}-account`}
|
id={`edit-${tier}-account`}
|
||||||
{...compositeForm.register(`tiers.${tier}.account`)}
|
{...compositeForm.register(`tiers.${tier}.account`)}
|
||||||
placeholder="account-id"
|
placeholder={t('cliproxyDialog.placeholderAccountId')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -321,43 +329,45 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-default-tier">Default Tier</Label>
|
<Label htmlFor="edit-default-tier">{t('cliproxyDialog.defaultTier')}</Label>
|
||||||
<select
|
<select
|
||||||
id="edit-default-tier"
|
id="edit-default-tier"
|
||||||
{...compositeForm.register('default_tier')}
|
{...compositeForm.register('default_tier')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="opus">Opus</option>
|
<option value="opus">{t('cliproxyDialog.opus')}</option>
|
||||||
<option value="sonnet">Sonnet</option>
|
<option value="sonnet">{t('cliproxyDialog.sonnet')}</option>
|
||||||
<option value="haiku">Haiku</option>
|
<option value="haiku">{t('cliproxyDialog.haiku')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-composite-target">Default Target</Label>
|
<Label htmlFor="edit-composite-target">{t('cliproxyDialog.defaultTarget')}</Label>
|
||||||
<select
|
<select
|
||||||
id="edit-composite-target"
|
id="edit-composite-target"
|
||||||
{...compositeForm.register('target')}
|
{...compositeForm.register('target')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="claude">Claude Code</option>
|
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
|
||||||
<option value="droid">Factory Droid</option>
|
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
{t('cliproxyDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
{updateMutation.isPending
|
||||||
|
? t('cliproxyDialog.saving')
|
||||||
|
: t('cliproxyDialog.saveChanges')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-provider">Provider</Label>
|
<Label htmlFor="edit-provider">{t('cliproxyDialog.provider')}</Label>
|
||||||
<select
|
<select
|
||||||
id="edit-provider"
|
id="edit-provider"
|
||||||
{...singleForm.register('provider')}
|
{...singleForm.register('provider')}
|
||||||
@@ -372,37 +382,43 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-model">Model</Label>
|
<Label htmlFor="edit-model">{t('cliproxyDialog.model')}</Label>
|
||||||
<Input id="edit-model" {...singleForm.register('model')} placeholder="model-id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="edit-account">Account (optional)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="edit-account"
|
id="edit-model"
|
||||||
{...singleForm.register('account')}
|
{...singleForm.register('model')}
|
||||||
placeholder="account-id"
|
placeholder={t('cliproxyDialog.placeholderModelId')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-target">Default Target</Label>
|
<Label htmlFor="edit-account">{t('cliproxyDialog.accountOptional')}</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-account"
|
||||||
|
{...singleForm.register('account')}
|
||||||
|
placeholder={t('cliproxyDialog.placeholderAccountId')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-target">{t('cliproxyDialog.defaultTarget')}</Label>
|
||||||
<select
|
<select
|
||||||
id="edit-target"
|
id="edit-target"
|
||||||
{...singleForm.register('target')}
|
{...singleForm.register('target')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="claude">Claude Code</option>
|
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
|
||||||
<option value="droid">Factory Droid</option>
|
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
{t('cliproxyDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
{updateMutation.isPending
|
||||||
|
? t('cliproxyDialog.saving')
|
||||||
|
: t('cliproxyDialog.saveChanges')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
|||||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
|
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ function CredentialRow({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
privacyMode,
|
privacyMode,
|
||||||
}: CredentialRowProps) {
|
}: CredentialRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
ready: {
|
ready: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
@@ -62,21 +65,21 @@ function CredentialRow({
|
|||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
const formatLastUsed = (date?: string) => {
|
const formatLastUsed = (date?: string) => {
|
||||||
if (!date) return 'Never used';
|
if (!date) return t('credentialHealth.neverUsed');
|
||||||
try {
|
try {
|
||||||
const lastUsed = new Date(date);
|
const lastUsed = new Date(date);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - lastUsed.getTime();
|
const diff = now.getTime() - lastUsed.getTime();
|
||||||
if (diff < 0) return 'Just now';
|
if (diff < 0) return t('credentialHealth.justNow');
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
const hours = Math.floor(diff / 3600000);
|
const hours = Math.floor(diff / 3600000);
|
||||||
const days = Math.floor(diff / 86400000);
|
const days = Math.floor(diff / 86400000);
|
||||||
if (days > 0) return `${days}d ago`;
|
if (days > 0) return t('credentialHealth.daysAgo', { count: days });
|
||||||
if (hours > 0) return `${hours}h ago`;
|
if (hours > 0) return t('credentialHealth.hoursAgo', { count: hours });
|
||||||
if (minutes > 0) return `${minutes}m ago`;
|
if (minutes > 0) return t('credentialHealth.minutesAgo', { count: minutes });
|
||||||
return 'Just now';
|
return t('credentialHealth.justNow');
|
||||||
} catch {
|
} catch {
|
||||||
return 'Unknown';
|
return t('credentialHealth.unknown');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,10 +123,12 @@ function CredentialRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CredentialHealthSkeleton() {
|
function CredentialHealthSkeleton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -137,6 +142,7 @@ function CredentialHealthSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CredentialHealthList() {
|
export function CredentialHealthList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: authData, isLoading } = useCliproxyAuth();
|
const { data: authData, isLoading } = useCliproxyAuth();
|
||||||
const { data: stats } = useCliproxyStats(true);
|
const { data: stats } = useCliproxyStats(true);
|
||||||
const { privacyMode } = usePrivacy();
|
const { privacyMode } = usePrivacy();
|
||||||
@@ -155,7 +161,8 @@ export function CredentialHealthList() {
|
|||||||
name: account.id,
|
name: account.id,
|
||||||
provider: status.provider,
|
provider: status.provider,
|
||||||
status: (account as { status?: CredentialStatus }).status ?? 'ready',
|
status: (account as { status?: CredentialStatus }).status ?? 'ready',
|
||||||
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
|
statusMessage:
|
||||||
|
(account as { statusMessage?: string }).statusMessage ?? t('credentialHealth.ready'),
|
||||||
email: account.email,
|
email: account.email,
|
||||||
lastUsedAt: runtimeLastUsed || account.lastUsedAt,
|
lastUsedAt: runtimeLastUsed || account.lastUsedAt,
|
||||||
};
|
};
|
||||||
@@ -166,11 +173,11 @@ export function CredentialHealthList() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No credentials configured. Use the login buttons above to authenticate.
|
{t('credentialHealth.noCredentialsConfigured')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -180,7 +187,7 @@ export function CredentialHealthList() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Credential Health</CardTitle>
|
<CardTitle className="text-base">{t('credentialHealth.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{credentials.map((cred, i) => (
|
{credentials.map((cred, i) => (
|
||||||
|
|||||||
@@ -10,31 +10,34 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Cpu, AlertCircle } from 'lucide-react';
|
import { Cpu, AlertCircle } from 'lucide-react';
|
||||||
import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
import { useCliproxyModels, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** Category display configuration */
|
/** Category display configuration */
|
||||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
|
const CATEGORY_CONFIG: Record<string, { key: string; color: string; bgColor: string }> = {
|
||||||
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
google: { key: 'google', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
|
openai: { key: 'openai', color: 'text-green-600', bgColor: 'bg-green-50' },
|
||||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
anthropic: { key: 'anthropic', color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
||||||
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
antigravity: { key: 'antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
||||||
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
other: { key: 'other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCategoryDisplay(category: string) {
|
function getCategoryDisplay(category: string) {
|
||||||
|
const normalized = category.toLowerCase();
|
||||||
|
const configured = CATEGORY_CONFIG[normalized];
|
||||||
return (
|
return (
|
||||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
configured || {
|
||||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
key: 'other',
|
||||||
color: 'text-gray-600',
|
color: 'text-gray-600',
|
||||||
bgColor: 'bg-gray-50',
|
bgColor: 'bg-gray-50',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelPreferencesSkeleton() {
|
function ModelPreferencesSkeleton({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Available Models</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -47,21 +50,27 @@ function ModelPreferencesSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyModelsState() {
|
function EmptyModelsState({
|
||||||
|
title,
|
||||||
|
noModels,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
noModels: string;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Available Models</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||||
<AlertCircle className="w-6 h-6 text-muted-foreground" />
|
<AlertCircle className="w-6 h-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-1">No models available</p>
|
<p className="text-sm text-muted-foreground mb-1">{noModels}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||||
Start a CLIProxy session to fetch available models
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -69,6 +78,7 @@ function EmptyModelsState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelPreferencesGrid() {
|
export function ModelPreferencesGrid() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: modelsData, isLoading, isError } = useCliproxyModels();
|
const { data: modelsData, isLoading, isError } = useCliproxyModels();
|
||||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
|
const backendLabel = updateCheck?.backendLabel ?? 'CLIProxy';
|
||||||
@@ -86,11 +96,17 @@ export function ModelPreferencesGrid() {
|
|||||||
}, [modelsData]);
|
}, [modelsData]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ModelPreferencesSkeleton />;
|
return <ModelPreferencesSkeleton title={t('cliproxyOverviewComponents.availableModels')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !modelsData || modelsData.totalCount === 0) {
|
if (isError || !modelsData || modelsData.totalCount === 0) {
|
||||||
return <EmptyModelsState />;
|
return (
|
||||||
|
<EmptyModelsState
|
||||||
|
title={t('cliproxyOverviewComponents.availableModels')}
|
||||||
|
noModels={t('cliproxyOverviewComponents.noModelsAvailable')}
|
||||||
|
hint={t('cliproxyOverviewComponents.startSessionHint')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,13 +114,13 @@ export function ModelPreferencesGrid() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Cpu className="w-4 h-4" />
|
<Cpu className="w-4 h-4" />
|
||||||
Available Models
|
{t('cliproxyOverviewComponents.availableModels')}
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{modelsData.totalCount} total
|
{t('cliproxyOverviewComponents.totalCount', { count: modelsData.totalCount })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Models available through {backendLabel}, grouped by provider
|
{t('cliproxyOverviewComponents.availableThroughGroupedByProvider', { backendLabel })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -115,7 +131,9 @@ export function ModelPreferencesGrid() {
|
|||||||
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
|
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className={cn('font-medium text-sm', display.color)}>{display.name}</h3>
|
<h3 className={cn('font-medium text-sm', display.color)}>
|
||||||
|
{t(`cliproxyModelCategory.${display.key}`)}
|
||||||
|
</h3>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{models.length}
|
{models.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -132,7 +150,7 @@ export function ModelPreferencesGrid() {
|
|||||||
))}
|
))}
|
||||||
{models.length > 5 && (
|
{models.length > 5 && (
|
||||||
<div className="text-xs text-muted-foreground italic">
|
<div className="text-xs text-muted-foreground italic">
|
||||||
+{models.length - 5} more
|
{t('cliproxyOverviewComponents.moreCount', { count: models.length - 5 })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
|
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
|
||||||
import { useCliproxyStats } from '@/hooks/use-cliproxy';
|
import { useCliproxyStats } from '@/hooks/use-cliproxy';
|
||||||
@@ -45,6 +46,7 @@ function StatsSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuickStatsRow() {
|
export function QuickStatsRow() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: stats, isLoading } = useCliproxyStats();
|
const { data: stats, isLoading } = useCliproxyStats();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -71,16 +73,24 @@ export function QuickStatsRow() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Activity className="w-4 h-4" />}
|
icon={<Activity className="w-4 h-4" />}
|
||||||
label="Total Requests"
|
label={t('quickStats.totalRequests')}
|
||||||
value={totalRequests}
|
value={totalRequests}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<CheckCircle2 className="w-4 h-4" />}
|
icon={<CheckCircle2 className="w-4 h-4" />}
|
||||||
label="Success Rate"
|
label={t('quickStats.successRate')}
|
||||||
value={`${successRate}%`}
|
value={`${successRate}%`}
|
||||||
/>
|
/>
|
||||||
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} />
|
<StatCard
|
||||||
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} />
|
icon={<Coins className="w-4 h-4" />}
|
||||||
|
label={t('quickStats.totalTokens')}
|
||||||
|
value={tokenDisplay}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Cpu className="w-4 h-4" />}
|
||||||
|
label={t('quickStats.activeModels')}
|
||||||
|
value={modelCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { Sparkles, Loader2, Star, Zap } from 'lucide-react';
|
import { Sparkles, Loader2, Star, Zap } from 'lucide-react';
|
||||||
import { FlexibleModelSelector } from '../provider-model-selector';
|
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||||
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function CustomPresetDialog({
|
export function CustomPresetDialog({
|
||||||
open,
|
open,
|
||||||
@@ -29,6 +30,7 @@ export function CustomPresetDialog({
|
|||||||
catalog,
|
catalog,
|
||||||
allModels,
|
allModels,
|
||||||
}: CustomPresetDialogProps) {
|
}: CustomPresetDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
||||||
const [presetName, setPresetName] = useState('');
|
const [presetName, setPresetName] = useState('');
|
||||||
|
|
||||||
@@ -48,54 +50,54 @@ export function CustomPresetDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Custom Preset
|
{t('customPresetDialog.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preset-name">Preset Name (optional)</Label>
|
<Label htmlFor="preset-name">{t('customPresetDialog.presetNameOptional')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="preset-name"
|
id="preset-name"
|
||||||
value={presetName}
|
value={presetName}
|
||||||
onChange={(e) => setPresetName(e.target.value)}
|
onChange={(e) => setPresetName(e.target.value)}
|
||||||
placeholder="e.g., My Custom Config"
|
placeholder={t('customPresetDialog.presetNamePlaceholder')}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
{catalog?.provider === 'codex' && (
|
{catalog?.provider === 'codex' && (
|
||||||
<p className="text-[11px] text-muted-foreground rounded-md border bg-muted/30 px-2.5 py-2">
|
<p className="text-[11px] text-muted-foreground rounded-md border bg-muted/30 px-2.5 py-2">
|
||||||
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
{t('customPresetDialog.codexTipPrefix')} <code>-medium</code>, <code>-high</code>, and{' '}
|
||||||
pin effort. Unsuffixed models use Thinking settings.
|
<code>-xhigh</code> {t('customPresetDialog.codexTipSuffix')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Default Model"
|
label={t('customPresetDialog.defaultModel')}
|
||||||
description="Used when no specific tier is requested"
|
description={t('customPresetDialog.defaultModelDesc')}
|
||||||
value={values.default}
|
value={values.default}
|
||||||
onChange={(model) => setValues({ ...values, default: model })}
|
onChange={(model) => setValues({ ...values, default: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Opus (Most capable)"
|
label={t('customPresetDialog.opusModel')}
|
||||||
description="For complex reasoning tasks"
|
description={t('customPresetDialog.opusModelDesc')}
|
||||||
value={values.opus}
|
value={values.opus}
|
||||||
onChange={(model) => setValues({ ...values, opus: model })}
|
onChange={(model) => setValues({ ...values, opus: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Sonnet (Balanced)"
|
label={t('customPresetDialog.sonnetModel')}
|
||||||
description="Balance of speed and capability"
|
description={t('customPresetDialog.sonnetModelDesc')}
|
||||||
value={values.sonnet}
|
value={values.sonnet}
|
||||||
onChange={(model) => setValues({ ...values, sonnet: model })}
|
onChange={(model) => setValues({ ...values, sonnet: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Haiku (Fast)"
|
label={t('customPresetDialog.haikuModel')}
|
||||||
description="Quick responses for simple tasks"
|
description={t('customPresetDialog.haikuModelDesc')}
|
||||||
value={values.haiku}
|
value={values.haiku}
|
||||||
onChange={(model) => setValues({ ...values, haiku: model })}
|
onChange={(model) => setValues({ ...values, haiku: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
@@ -104,7 +106,7 @@ export function CustomPresetDialog({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{t('customPresetDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
{onSave && (
|
{onSave && (
|
||||||
<Button
|
<Button
|
||||||
@@ -117,12 +119,12 @@ export function CustomPresetDialog({
|
|||||||
) : (
|
) : (
|
||||||
<Star className="w-4 h-4 mr-1" />
|
<Star className="w-4 h-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Save Preset
|
{t('customPresetDialog.savePreset')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => onApply(values, presetName || undefined)}>
|
<Button onClick={() => onApply(values, presetName || undefined)}>
|
||||||
<Zap className="w-4 h-4 mr-1" />
|
<Zap className="w-4 h-4 mr-1" />
|
||||||
Apply Preset
|
{t('customPresetDialog.applyPreset')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useDeletePreset,
|
useDeletePreset,
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { useProviderEditor } from './use-provider-editor';
|
import { useProviderEditor } from './use-provider-editor';
|
||||||
import { CustomPresetDialog } from './custom-preset-dialog';
|
import { CustomPresetDialog } from './custom-preset-dialog';
|
||||||
@@ -155,7 +156,7 @@ export function ProviderEditor({
|
|||||||
|
|
||||||
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
|
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
|
||||||
if (!presetName) {
|
if (!presetName) {
|
||||||
toast.error('Please enter a preset name to save');
|
toast.error(i18n.t('commonToast.enterPresetName'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
|
import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** Model entry from catalog */
|
/** Model entry from catalog */
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
@@ -72,9 +73,12 @@ export function ProviderModelSelector({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder = 'Select a model',
|
placeholder,
|
||||||
className,
|
className,
|
||||||
}: ProviderModelSelectorProps) {
|
}: ProviderModelSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel');
|
||||||
|
|
||||||
// Group models by tier
|
// Group models by tier
|
||||||
const groupedModels = useMemo(() => {
|
const groupedModels = useMemo(() => {
|
||||||
if (!catalog?.models) return { free: [], paid: [] };
|
if (!catalog?.models) return { free: [], paid: [] };
|
||||||
@@ -95,7 +99,7 @@ export function ProviderModelSelector({
|
|||||||
if (!catalog || catalog.models.length === 0) {
|
if (!catalog || catalog.models.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('text-sm text-muted-foreground py-2', className)}>
|
<div className={cn('text-sm text-muted-foreground py-2', className)}>
|
||||||
No models available for this provider
|
{t('providerModelSelector.noModelsForProvider')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,12 +114,12 @@ export function ProviderModelSelector({
|
|||||||
<span className="truncate">{model.name}</span>
|
<span className="truncate">{model.name}</span>
|
||||||
{model.broken && (
|
{model.broken && (
|
||||||
<Badge variant="destructive" className="text-[9px] h-4 px-1">
|
<Badge variant="destructive" className="text-[9px] h-4 px-1">
|
||||||
BROKEN
|
{t('providerModelSelector.broken')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{model.deprecated && (
|
{model.deprecated && (
|
||||||
<Badge variant="secondary" className="text-[9px] h-4 px-1">
|
<Badge variant="secondary" className="text-[9px] h-4 px-1">
|
||||||
DEPRECATED
|
{t('providerModelSelector.deprecated')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||||
@@ -127,13 +131,13 @@ export function ProviderModelSelector({
|
|||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={placeholder}>
|
<SelectValue placeholder={resolvedPlaceholder}>
|
||||||
{selectedModel && (
|
{selectedModel && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate">{selectedModel.name}</span>
|
<span className="truncate">{selectedModel.name}</span>
|
||||||
{selectedModel.tier === 'paid' && (
|
{selectedModel.tier === 'paid' && (
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
PAID
|
{t('providerModelSelector.paid')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +147,17 @@ export function ProviderModelSelector({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{groupedModels.free.length > 0 && (
|
{groupedModels.free.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-muted-foreground">Free Tier</SelectLabel>
|
<SelectLabel className="text-xs text-muted-foreground">
|
||||||
|
{t('providerModelSelector.freeTier')}
|
||||||
|
</SelectLabel>
|
||||||
{groupedModels.free.map(renderModelItem)}
|
{groupedModels.free.map(renderModelItem)}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
)}
|
)}
|
||||||
{groupedModels.paid.length > 0 && (
|
{groupedModels.paid.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-amber-600">Paid Tier</SelectLabel>
|
<SelectLabel className="text-xs text-amber-600">
|
||||||
|
{t('providerModelSelector.paidTier')}
|
||||||
|
</SelectLabel>
|
||||||
{groupedModels.paid.map(renderModelItem)}
|
{groupedModels.paid.map(renderModelItem)}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +169,7 @@ export function ProviderModelSelector({
|
|||||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
|
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">This model has known issues</p>
|
<p className="font-medium">{t('providerModelSelector.modelKnownIssues')}</p>
|
||||||
{selectedModel.issueUrl && (
|
{selectedModel.issueUrl && (
|
||||||
<a
|
<a
|
||||||
href={selectedModel.issueUrl}
|
href={selectedModel.issueUrl}
|
||||||
@@ -169,7 +177,7 @@ export function ProviderModelSelector({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline hover:no-underline"
|
className="underline hover:no-underline"
|
||||||
>
|
>
|
||||||
View issue details
|
{t('providerModelSelector.viewIssueDetails')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +188,7 @@ export function ProviderModelSelector({
|
|||||||
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
|
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">This model is deprecated</p>
|
<p className="font-medium">{t('providerModelSelector.modelDeprecated')}</p>
|
||||||
{selectedModel.deprecationReason && (
|
{selectedModel.deprecationReason && (
|
||||||
<p className="opacity-80">{selectedModel.deprecationReason}</p>
|
<p className="opacity-80">{selectedModel.deprecationReason}</p>
|
||||||
)}
|
)}
|
||||||
@@ -212,6 +220,7 @@ export function ModelMappingSelector({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: ModelMappingSelectorProps) {
|
}: ModelMappingSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!catalog) return null;
|
if (!catalog) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,7 +228,7 @@ export function ModelMappingSelector({
|
|||||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||||
<SelectTrigger className="h-8 text-sm">
|
<SelectTrigger className="h-8 text-sm">
|
||||||
<SelectValue placeholder="Select model">
|
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
|
||||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -230,7 +239,7 @@ export function ModelMappingSelector({
|
|||||||
<span className="truncate text-sm">{model.name}</span>
|
<span className="truncate text-sm">{model.name}</span>
|
||||||
{model.tier === 'paid' && (
|
{model.tier === 'paid' && (
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
PAID
|
{t('providerModelSelector.paid')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -262,6 +271,7 @@ export function FlexibleModelSelector({
|
|||||||
allModels,
|
allModels,
|
||||||
disabled,
|
disabled,
|
||||||
}: FlexibleModelSelectorProps) {
|
}: FlexibleModelSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Combine catalog models (recommended) with all available models
|
// Combine catalog models (recommended) with all available models
|
||||||
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
|
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
|
||||||
const isCodexProvider = catalog?.provider === 'codex';
|
const isCodexProvider = catalog?.provider === 'codex';
|
||||||
@@ -275,7 +285,7 @@ export function FlexibleModelSelector({
|
|||||||
</div>
|
</div>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9">
|
||||||
<SelectValue placeholder="Select model">
|
<SelectValue placeholder={t('providerModelSelector.selectModel')}>
|
||||||
{value && (
|
{value && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-mono text-xs">{value}</span>
|
<span className="truncate font-mono text-xs">{value}</span>
|
||||||
@@ -295,7 +305,9 @@ export function FlexibleModelSelector({
|
|||||||
{/* Recommended models from catalog */}
|
{/* Recommended models from catalog */}
|
||||||
{catalog && catalog.models.length > 0 && (
|
{catalog && catalog.models.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
|
<SelectLabel className="text-xs text-primary">
|
||||||
|
{t('providerModelSelector.recommended')}
|
||||||
|
</SelectLabel>
|
||||||
{catalog.models.map((model) => {
|
{catalog.models.map((model) => {
|
||||||
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
|
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
|
||||||
return (
|
return (
|
||||||
@@ -304,7 +316,7 @@ export function FlexibleModelSelector({
|
|||||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||||
{model.tier === 'paid' && (
|
{model.tier === 'paid' && (
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
PAID
|
{t('providerModelSelector.paid')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{codexEffort && (
|
{codexEffort && (
|
||||||
@@ -327,7 +339,7 @@ export function FlexibleModelSelector({
|
|||||||
{allModels.length > 0 && (
|
{allModels.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-muted-foreground">
|
<SelectLabel className="text-xs text-muted-foreground">
|
||||||
All Models ({allModels.length})
|
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{allModels
|
{allModels
|
||||||
.filter((m) => !catalogModelIds.has(m.id))
|
.filter((m) => !catalogModelIds.has(m.id))
|
||||||
@@ -355,7 +367,9 @@ export function FlexibleModelSelector({
|
|||||||
|
|
||||||
{/* Fallback if no models available */}
|
{/* Fallback if no models available */}
|
||||||
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
|
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
|
||||||
<div className="py-2 px-3 text-xs text-muted-foreground">No models available</div>
|
<div className="py-2 px-3 text-xs text-muted-foreground">
|
||||||
|
{t('providerModelSelector.noModelsAvailable')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Loader2, Upload, CheckCircle } from 'lucide-react';
|
import { Loader2, Upload, CheckCircle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface SyncDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: preview, isLoading: previewLoading } = useSyncPreview();
|
const { data: preview, isLoading: previewLoading } = useSyncPreview();
|
||||||
const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync();
|
const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync();
|
||||||
|
|
||||||
@@ -45,11 +47,9 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5" />
|
<Upload className="w-5 h-5" />
|
||||||
Sync Profiles to Local CLIProxy
|
{t('syncDialog.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('syncDialog.description')}</DialogDescription>
|
||||||
Sync your CCS API profiles to the local CLIProxy config.yaml.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -59,8 +59,8 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : preview?.count === 0 ? (
|
) : preview?.count === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<p>No profiles configured to sync.</p>
|
<p>{t('syncDialog.noProfiles')}</p>
|
||||||
<p className="text-sm mt-2">Create API profiles first using the Profiles tab.</p>
|
<p className="text-sm mt-2">{t('syncDialog.createProfilesFirst')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[300px] pr-4">
|
<ScrollArea className="h-[300px] pr-4">
|
||||||
@@ -74,7 +74,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
<div className="font-medium">{profile.name}</div>
|
<div className="font-medium">{profile.name}</div>
|
||||||
{profile.modelName && (
|
{profile.modelName && (
|
||||||
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
|
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||||
Model: {profile.modelName}
|
{t('syncDialog.modelLabel')} {profile.modelName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{profile.baseUrl && (
|
{profile.baseUrl && (
|
||||||
@@ -84,7 +84,7 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
Ready
|
{t('syncDialog.ready')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -94,11 +94,11 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync
|
{t('syncDialog.profilesToSync', { count: preview?.count ?? 0 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="outline" onClick={handleClose}>
|
||||||
Cancel
|
{t('syncDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
@@ -108,17 +108,17 @@ export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
|
|||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Syncing...
|
{t('syncDialog.syncing')}
|
||||||
</>
|
</>
|
||||||
) : isSuccess ? (
|
) : isSuccess ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
Synced!
|
{t('syncDialog.synced')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Sync Now
|
{t('syncDialog.syncNow')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react';
|
import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SyncDialog } from './sync-dialog';
|
import { SyncDialog } from './sync-dialog';
|
||||||
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
||||||
|
|
||||||
export function SyncStatusCard() {
|
export function SyncStatusCard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const { data: status, isLoading, refetch } = useSyncStatus();
|
const { data: status, isLoading, refetch } = useSyncStatus();
|
||||||
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
|
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
|
||||||
@@ -31,7 +33,7 @@ export function SyncStatusCard() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
<FileDown className="w-4 h-4" />
|
<FileDown className="w-4 h-4" />
|
||||||
Profile Sync
|
{t('syncStatusCard.profileSync')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-center py-6">
|
<CardContent className="flex items-center justify-center py-6">
|
||||||
@@ -50,7 +52,7 @@ export function SyncStatusCard() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
<FileDown className="w-4 h-4" />
|
<FileDown className="w-4 h-4" />
|
||||||
Profile Sync
|
{t('syncStatusCard.profileSync')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge
|
<Badge
|
||||||
variant={isConfigured ? 'default' : 'secondary'}
|
variant={isConfigured ? 'default' : 'secondary'}
|
||||||
@@ -62,22 +64,24 @@ export function SyncStatusCard() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isConfigured ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
|
{isConfigured ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
|
||||||
{isConfigured ? 'Ready' : 'No Config'}
|
{isConfigured ? t('syncStatusCard.ready') : t('syncStatusCard.noConfig')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{isConfigured && (
|
{isConfigured && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Syncs API profiles to local CLIProxy config
|
{t('syncStatusCard.syncsProfilesDesc')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isConfigured && (
|
{!isConfigured && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p
|
||||||
Run <code className="bg-muted px-1 rounded">ccs doctor --fix</code> to generate
|
className="text-xs text-muted-foreground"
|
||||||
config.
|
dangerouslySetInnerHTML={{
|
||||||
</p>
|
__html: t('syncStatusCard.runDoctorHint'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.error && <p className="text-xs text-red-500">{status.error}</p>}
|
{status?.error && <p className="text-xs text-red-500">{status.error}</p>}
|
||||||
@@ -89,7 +93,7 @@ export function SyncStatusCard() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => setDialogOpen(true)}
|
onClick={() => setDialogOpen(true)}
|
||||||
>
|
>
|
||||||
Details
|
{t('syncStatusCard.details')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -103,7 +107,7 @@ export function SyncStatusCard() {
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw className="w-3 h-3" />
|
<RefreshCw className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
Sync Now
|
{t('syncStatusCard.syncNow')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CodeEditor } from '@/components/shared/code-editor';
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface RawJsonSettingsEditorPanelProps {
|
interface RawJsonSettingsEditorPanelProps {
|
||||||
@@ -39,7 +40,7 @@ export function RawJsonSettingsEditorPanel({
|
|||||||
if (!value) return;
|
if (!value) return;
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
toast.success('Settings copied to clipboard');
|
toast.success(i18n.t('commonToast.settingsCopied'));
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import type { FlexibleModelSelectorProps } from './types';
|
import type { FlexibleModelSelectorProps } from './types';
|
||||||
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
|
import { getPlanBadgeStyle, getMultiplierDisplay } from './utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function FlexibleModelSelector({
|
export function FlexibleModelSelector({
|
||||||
label,
|
label,
|
||||||
@@ -25,6 +26,7 @@ export function FlexibleModelSelector({
|
|||||||
models,
|
models,
|
||||||
disabled,
|
disabled,
|
||||||
}: FlexibleModelSelectorProps) {
|
}: FlexibleModelSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Find current model for display
|
// Find current model for display
|
||||||
const currentModel = models.find((m) => m.id === value);
|
const currentModel = models.find((m) => m.id === value);
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export function FlexibleModelSelector({
|
|||||||
</div>
|
</div>
|
||||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9">
|
||||||
<SelectValue placeholder="Select model">
|
<SelectValue placeholder={t('componentModelSelector.selectModel')}>
|
||||||
{value && (
|
{value && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-mono text-xs">{value}</span>
|
<span className="truncate font-mono text-xs">{value}</span>
|
||||||
@@ -55,7 +57,7 @@ export function FlexibleModelSelector({
|
|||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-muted-foreground">
|
<SelectLabel className="text-xs text-muted-foreground">
|
||||||
Available Models ({models.length})
|
{t('componentModelSelector.availableModelsCount', { count: models.length })}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<SelectItem key={model.id} value={model.id}>
|
<SelectItem key={model.id} value={model.id}>
|
||||||
@@ -76,7 +78,7 @@ export function FlexibleModelSelector({
|
|||||||
)}
|
)}
|
||||||
{model.preview && (
|
{model.preview && (
|
||||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
||||||
Preview
|
{t('componentModelSelector.preview')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface SettingsTabProps {
|
interface SettingsTabProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -46,6 +47,8 @@ export function SettingsTab({
|
|||||||
onUpdateRateLimit,
|
onUpdateRateLimit,
|
||||||
onUpdateWaitOnLimit,
|
onUpdateWaitOnLimit,
|
||||||
}: SettingsTabProps) {
|
}: SettingsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="settings"
|
value="settings"
|
||||||
@@ -57,10 +60,10 @@ export function SettingsTab({
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||||
Enable Copilot
|
{t('copilotSettings.enableCopilot')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Allow using GitHub Copilot subscription
|
{t('copilotSettings.enableCopilotDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} />
|
<Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} />
|
||||||
@@ -68,12 +71,12 @@ export function SettingsTab({
|
|||||||
|
|
||||||
{/* Basic Settings */}
|
{/* Basic Settings */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Basic Settings</h3>
|
<h3 className="text-sm font-medium">{t('copilotSettings.basicSettings')}</h3>
|
||||||
|
|
||||||
{/* Port */}
|
{/* Port */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="port" className="text-xs">
|
<Label htmlFor="port" className="text-xs">
|
||||||
Port
|
{t('copilotPage.port')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="port"
|
id="port"
|
||||||
@@ -89,16 +92,22 @@ export function SettingsTab({
|
|||||||
{/* Account Type */}
|
{/* Account Type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="account-type" className="text-xs">
|
<Label htmlFor="account-type" className="text-xs">
|
||||||
Account Type
|
{t('copilotSettings.accountType')}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={accountType} onValueChange={onUpdateAccountType}>
|
<Select value={accountType} onValueChange={onUpdateAccountType}>
|
||||||
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
|
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="individual">Individual</SelectItem>
|
<SelectItem value="individual">
|
||||||
<SelectItem value="business">Business</SelectItem>
|
{t('copilotSettings.accountTypeIndividual')}
|
||||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="business">
|
||||||
|
{t('copilotSettings.accountTypeBusiness')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="enterprise">
|
||||||
|
{t('copilotSettings.accountTypeEnterprise')}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,18 +117,18 @@ export function SettingsTab({
|
|||||||
|
|
||||||
{/* Rate Limiting */}
|
{/* Rate Limiting */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Rate Limiting</h3>
|
<h3 className="text-sm font-medium">{t('copilotSettings.rateLimiting')}</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="rate-limit" className="text-xs">
|
<Label htmlFor="rate-limit" className="text-xs">
|
||||||
Rate Limit (seconds)
|
{t('copilotSettings.rateLimitSeconds')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="rate-limit"
|
id="rate-limit"
|
||||||
type="number"
|
type="number"
|
||||||
value={rateLimit}
|
value={rateLimit}
|
||||||
onChange={(e) => onUpdateRateLimit(e.target.value)}
|
onChange={(e) => onUpdateRateLimit(e.target.value)}
|
||||||
placeholder="No limit"
|
placeholder={t('copilotSettings.noLimit')}
|
||||||
min={0}
|
min={0}
|
||||||
className="max-w-[150px] h-8"
|
className="max-w-[150px] h-8"
|
||||||
/>
|
/>
|
||||||
@@ -128,10 +137,10 @@ export function SettingsTab({
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="wait-on-limit" className="text-xs">
|
<Label htmlFor="wait-on-limit" className="text-xs">
|
||||||
Wait on Rate Limit
|
{t('copilotSettings.waitOnRateLimit')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Wait instead of error when limit hit
|
{t('copilotSettings.waitOnRateLimitDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -146,15 +155,15 @@ export function SettingsTab({
|
|||||||
|
|
||||||
{/* Daemon Settings */}
|
{/* Daemon Settings */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Daemon Settings</h3>
|
<h3 className="text-sm font-medium">{t('copilotSettings.daemonSettings')}</h3>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="auto-start" className="text-xs">
|
<Label htmlFor="auto-start" className="text-xs">
|
||||||
Auto-start Daemon
|
{t('copilotSettings.autoStartDaemon')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Start copilot-api when using profile
|
{t('copilotSettings.autoStartDaemonDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="auto-start" checked={autoStart} onCheckedChange={onUpdateAutoStart} />
|
<Switch id="auto-start" checked={autoStart} onCheckedChange={onUpdateAutoStart} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
|
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
|
||||||
@@ -14,6 +15,7 @@ const statusConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const fixMutation = useFixHealth();
|
const fixMutation = useFixHealth();
|
||||||
const config = statusConfig[check.status];
|
const config = statusConfig[check.status];
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -21,7 +23,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
|||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
toast.success('Copied to clipboard');
|
toast.success(t('health.copied'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compact single-line display for items without expandable content
|
// Compact single-line display for items without expandable content
|
||||||
@@ -62,7 +64,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
|||||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<Wrench className="w-3 h-3 mr-1" />
|
<Wrench className="w-3 h-3 mr-1" />
|
||||||
Fix
|
{t('health.fix')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +151,7 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
|||||||
className="h-7 px-3 text-xs"
|
className="h-7 px-3 text-xs"
|
||||||
>
|
>
|
||||||
<Wrench className="w-3 h-3 mr-1" />
|
<Wrench className="w-3 h-3 mr-1" />
|
||||||
Apply Fix
|
{t('health.applyFix')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface HealthGaugeProps {
|
interface HealthGaugeProps {
|
||||||
passed: number;
|
passed: number;
|
||||||
@@ -20,6 +21,7 @@ const statusColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
|
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const config = sizeConfig[size];
|
const config = sizeConfig[size];
|
||||||
const colors = statusColors[status];
|
const colors = statusColors[status];
|
||||||
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
|
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
|
||||||
@@ -83,7 +85,7 @@ export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeP
|
|||||||
config.labelSize
|
config.labelSize
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
health
|
{t('health.healthLabel')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface HealthStatsBarProps {
|
interface HealthStatsBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -28,6 +29,7 @@ function StatItem({ label, value, color, bgColor }: StatItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
|
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Calculate percentages for the progress bar
|
// Calculate percentages for the progress bar
|
||||||
const passedPct = (passed / total) * 100;
|
const passedPct = (passed / total) * 100;
|
||||||
const warningPct = (warnings / total) * 100;
|
const warningPct = (warnings / total) * 100;
|
||||||
@@ -68,7 +70,7 @@ export function HealthStatsBar({ total, passed, warnings, errors, info }: Health
|
|||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Checks
|
{t('health.checks')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono font-bold text-lg">{total}</span>
|
<span className="font-mono font-bold text-lg">{total}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useSidebar } from '@/hooks/use-sidebar';
|
|||||||
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface SidebarBadge {
|
interface SidebarBadge {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -62,66 +63,69 @@ interface SidebarGroupDef {
|
|||||||
items: SidebarItem[];
|
items: SidebarItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define navigation groups
|
function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
|
||||||
const navGroups: SidebarGroupDef[] = [
|
return [
|
||||||
{
|
{
|
||||||
title: 'General',
|
title: t('nav.general'),
|
||||||
items: [
|
items: [
|
||||||
{ path: '/', icon: Home, label: 'Home' },
|
{ path: '/', icon: Home, label: t('nav.home') },
|
||||||
{ path: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ path: '/analytics', icon: BarChart3, label: t('nav.analytics') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Identity & Access',
|
title: t('nav.identityAccess'),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: '/providers',
|
path: '/providers',
|
||||||
icon: Key,
|
icon: Key,
|
||||||
label: 'API Profiles',
|
label: t('nav.apiProfiles'),
|
||||||
badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' },
|
badge: { text: 'OpenRouter', icon: '/icons/openrouter.svg' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/cliproxy',
|
path: '/cliproxy',
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
label: 'CLIProxy Plus',
|
label: t('nav.cliproxyPlus'),
|
||||||
isCollapsible: true,
|
isCollapsible: true,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/cliproxy', label: 'Overview' },
|
{ path: '/cliproxy', label: t('nav.cliproxyOverview') },
|
||||||
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' },
|
{ path: '/cliproxy/control-panel', icon: Gauge, label: t('nav.controlPanel') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/copilot', icon: Github, label: 'GitHub Copilot' },
|
{ path: '/copilot', icon: Github, label: t('nav.githubCopilot') },
|
||||||
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' },
|
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') },
|
||||||
{
|
{
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: 'Accounts',
|
label: t('nav.accounts'),
|
||||||
isCollapsible: true,
|
isCollapsible: true,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/accounts', label: 'All Accounts' },
|
{ path: '/accounts', label: t('nav.allAccounts') },
|
||||||
{ path: '/shared', icon: FolderOpen, label: 'Shared Data' },
|
{ path: '/shared', icon: FolderOpen, label: t('nav.sharedData') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Compatible CLIs',
|
title: t('nav.compatibleClis'),
|
||||||
items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }],
|
items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'System',
|
title: t('nav.system'),
|
||||||
items: [
|
items: [
|
||||||
{ path: '/health', icon: Activity, label: 'Health' },
|
{ path: '/health', icon: Activity, label: t('nav.health') },
|
||||||
{ path: '/settings', icon: Settings, label: 'Settings' },
|
{ path: '/settings', icon: Settings, label: t('nav.settings') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
|
const navGroups = buildNavGroups(t);
|
||||||
|
|
||||||
// Dynamic label for CLIProxy based on backend
|
// Dynamic label for CLIProxy based on backend
|
||||||
const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy';
|
const cliproxyLabel = updateCheck?.backendLabel ?? 'CLIProxy';
|
||||||
@@ -236,7 +240,7 @@ export function AppSidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>349+ models via OpenRouter</p>
|
<p>{t('nav.openrouterTooltip')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { normalizeLocale, persistLocale, SUPPORTED_LOCALES } from '@/lib/locales';
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const currentLocale = normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? 'en');
|
||||||
|
|
||||||
|
const handleLocaleChange = (nextLocale: string) => {
|
||||||
|
const normalized = persistLocale(nextLocale);
|
||||||
|
void i18n.changeLanguage(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select value={currentLocale} onValueChange={handleLocaleChange}>
|
||||||
|
<SelectTrigger className="h-8 w-[150px]">
|
||||||
|
<SelectValue placeholder={t('layout.languageSwitcher')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPORTED_LOCALES.map((locale) => (
|
||||||
|
<SelectItem key={locale} value={locale}>
|
||||||
|
{t(`locale.${locale}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { SponsorButton } from '@/components/shared/sponsor-button';
|
|||||||
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
|
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
|
||||||
import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
|
import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
|
||||||
import { UserMenu } from '@/components/auth/user-menu';
|
import { UserMenu } from '@/components/auth/user-menu';
|
||||||
|
import { LanguageSwitcher } from './language-switcher';
|
||||||
import { useProjectSelection } from '@/hooks/use-project-selection';
|
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||||
import { useDeviceCode } from '@/hooks/use-device-code';
|
import { useDeviceCode } from '@/hooks/use-device-code';
|
||||||
import { storeLastRoute } from '@/lib/last-route';
|
import { storeLastRoute } from '@/lib/last-route';
|
||||||
@@ -47,6 +48,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ConnectionIndicator />
|
<ConnectionIndicator />
|
||||||
|
<LanguageSwitcher />
|
||||||
<DocsLink />
|
<DocsLink />
|
||||||
<GitHubLink />
|
<GitHubLink />
|
||||||
<PrivacyToggle />
|
<PrivacyToggle />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { STATUS_COLORS } from '@/lib/utils';
|
import { STATUS_COLORS } from '@/lib/utils';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||||
@@ -20,6 +21,7 @@ import { SummaryCard } from './components/summary-card';
|
|||||||
const STORAGE_KEY = 'auth-monitor-selected-provider';
|
const STORAGE_KEY = 'auth-monitor-selected-provider';
|
||||||
|
|
||||||
export function AuthMonitor() {
|
export function AuthMonitor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
accounts,
|
accounts,
|
||||||
totalSuccess,
|
totalSuccess,
|
||||||
@@ -117,7 +119,7 @@ export function AuthMonitor() {
|
|||||||
<span>Updated {timeSinceUpdate || 'now'}</span>
|
<span>Updated {timeSinceUpdate || 'now'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground/50">|</span>
|
<span className="text-muted-foreground/50">|</span>
|
||||||
<span>{accounts.length} accounts</span>
|
<span>{t('authMonitor.accountsCount', { count: accounts.length })}</span>
|
||||||
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
|
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,25 +128,25 @@ export function AuthMonitor() {
|
|||||||
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
|
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Activity className="w-4 h-4" />}
|
icon={<Activity className="w-4 h-4" />}
|
||||||
label="Accounts"
|
label={t('authMonitor.accounts')}
|
||||||
value={accounts.length}
|
value={accounts.length}
|
||||||
color="var(--accent)"
|
color="var(--accent)"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<CheckCircle2 className="w-4 h-4" />}
|
icon={<CheckCircle2 className="w-4 h-4" />}
|
||||||
label="Success"
|
label={t('authMonitor.success')}
|
||||||
value={totalSuccess.toLocaleString()}
|
value={totalSuccess.toLocaleString()}
|
||||||
color={STATUS_COLORS.success}
|
color={STATUS_COLORS.success}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<XCircle className="w-4 h-4" />}
|
icon={<XCircle className="w-4 h-4" />}
|
||||||
label="Failed"
|
label={t('authMonitor.failed')}
|
||||||
value={totalFailure.toLocaleString()}
|
value={totalFailure.toLocaleString()}
|
||||||
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
|
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
icon={<Activity className="w-4 h-4" />}
|
icon={<Activity className="w-4 h-4" />}
|
||||||
label="Success Rate"
|
label={t('authMonitor.successRate')}
|
||||||
value={`${overallSuccessRate}%`}
|
value={`${overallSuccessRate}%`}
|
||||||
color={
|
color={
|
||||||
overallSuccessRate === 100
|
overallSuccessRate === 100
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
|
import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ErrorLogItem } from './error-log-item';
|
import { ErrorLogItem } from './error-log-item';
|
||||||
import { LogContentPanel } from './log-content-panel';
|
import { LogContentPanel } from './log-content-panel';
|
||||||
|
|
||||||
export function ErrorLogsMonitor() {
|
export function ErrorLogsMonitor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
|
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
|
||||||
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
|
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
|
||||||
|
|
||||||
@@ -142,14 +144,14 @@ export function ErrorLogsMonitor() {
|
|||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||||
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
|
<span className="text-sm font-semibold tracking-tight">{t('errorLogs.title')}</span>
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
|
{t('errorLogs.failedRequests', { count: errorCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<FileWarning className="w-3.5 h-3.5" />
|
<FileWarning className="w-3.5 h-3.5" />
|
||||||
<span>CLIProxy Diagnostics</span>
|
<span>{t('errorLogs.cliproxyDiagnostics')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@ export function ErrorLogsMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
{logs.length > 50 && (
|
{logs.length > 50 && (
|
||||||
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
|
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
|
||||||
Showing 50 of {logs.length} logs
|
{t('errorLogs.showingCount', { total: logs.length })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
|
import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { parseErrorLog } from '@/lib/error-log-parser';
|
import { parseErrorLog } from '@/lib/error-log-parser';
|
||||||
import type { TabType, LogContentPanelProps } from './types';
|
import type { TabType, LogContentPanelProps } from './types';
|
||||||
import { TabButton, StatusBadge } from './ui-primitives';
|
import { TabButton, StatusBadge } from './ui-primitives';
|
||||||
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
|
import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components';
|
||||||
|
|
||||||
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
|
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
|||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<Terminal className="w-10 h-10 mx-auto opacity-40" />
|
<Terminal className="w-10 h-10 mx-auto opacity-40" />
|
||||||
<p className="text-sm">Select a log to view details</p>
|
<p className="text-sm">{t('errorLogs.selectLog')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -51,7 +53,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
|||||||
if (error || !content || !parsed) {
|
if (error || !content || !parsed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
<p className="text-sm">Failed to load log content</p>
|
<p className="text-sm">{t('errorLogs.failedLoadContent')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
|||||||
{name && (
|
{name && (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
value={absolutePath || name}
|
value={absolutePath || name}
|
||||||
label="Copy absolute path"
|
label={t('errorLogs.copyAbsolutePath')}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
|
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
|
||||||
/>
|
/>
|
||||||
@@ -80,7 +82,7 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
|||||||
{content && (
|
{content && (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
value={content}
|
value={content}
|
||||||
label="Copy raw log content"
|
label={t('errorLogs.copyRawContent')}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
@@ -99,31 +101,31 @@ export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) {
|
|||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
icon={Info}
|
icon={Info}
|
||||||
>
|
>
|
||||||
Overview
|
{t('errorLogs.tabOverview')}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'headers'}
|
active={activeTab === 'headers'}
|
||||||
onClick={() => setActiveTab('headers')}
|
onClick={() => setActiveTab('headers')}
|
||||||
icon={Code}
|
icon={Code}
|
||||||
>
|
>
|
||||||
Headers
|
{t('errorLogs.tabHeaders')}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'request'}
|
active={activeTab === 'request'}
|
||||||
onClick={() => setActiveTab('request')}
|
onClick={() => setActiveTab('request')}
|
||||||
icon={ArrowUpRight}
|
icon={ArrowUpRight}
|
||||||
>
|
>
|
||||||
Request
|
{t('errorLogs.tabRequest')}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'response'}
|
active={activeTab === 'response'}
|
||||||
onClick={() => setActiveTab('response')}
|
onClick={() => setActiveTab('response')}
|
||||||
icon={ArrowDownLeft}
|
icon={ArrowDownLeft}
|
||||||
>
|
>
|
||||||
Response
|
{t('errorLogs.tabResponse')}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
|
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
|
||||||
Raw
|
{t('errorLogs.tabRaw')}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
|
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
getErrorTypeLabel,
|
getErrorTypeLabel,
|
||||||
@@ -16,9 +17,11 @@ import { StatusBadge } from './ui-primitives';
|
|||||||
|
|
||||||
/** Overview tab content */
|
/** Overview tab content */
|
||||||
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const quotaResetDisplay =
|
const quotaResetDisplay =
|
||||||
formatQuotaResetDelay(parsed.quotaResetDelay) ||
|
formatQuotaResetDelay(parsed.quotaResetDelay) ||
|
||||||
formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
|
formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
|
||||||
|
const na = t('errorLogs.na');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
@@ -37,7 +40,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
|||||||
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
||||||
<Cpu className="w-4 h-4 text-violet-500 shrink-0" />
|
<Cpu className="w-4 h-4 text-violet-500 shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-muted-foreground">Model: </span>
|
<span className="text-muted-foreground">{t('errorLogs.modelLabel')}</span>
|
||||||
<span className="font-semibold text-violet-600 dark:text-violet-400">
|
<span className="font-semibold text-violet-600 dark:text-violet-400">
|
||||||
{parsed.model}
|
{parsed.model}
|
||||||
</span>
|
</span>
|
||||||
@@ -50,7 +53,7 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
|||||||
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
<Clock className="w-4 h-4 text-amber-500 shrink-0" />
|
<Clock className="w-4 h-4 text-amber-500 shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-muted-foreground">Quota resets in </span>
|
<span className="text-muted-foreground">{t('errorLogs.quotaResetsIn')}</span>
|
||||||
<span className="font-semibold text-amber-600 dark:text-amber-400">
|
<span className="font-semibold text-amber-600 dark:text-amber-400">
|
||||||
{quotaResetDisplay}
|
{quotaResetDisplay}
|
||||||
</span>
|
</span>
|
||||||
@@ -61,37 +64,37 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
|||||||
{/* Key metrics grid */}
|
{/* Key metrics grid */}
|
||||||
<div className="grid grid-cols-4 gap-3 text-xs">
|
<div className="grid grid-cols-4 gap-3 text-xs">
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
<div className="text-muted-foreground mb-1">Method</div>
|
<div className="text-muted-foreground mb-1">{t('errorLogs.method')}</div>
|
||||||
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
<div className="font-medium">{parsed.method || na}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
<div className="text-muted-foreground mb-1">Provider</div>
|
<div className="text-muted-foreground mb-1">{t('errorLogs.provider')}</div>
|
||||||
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
<div className="font-medium">{parsed.provider || na}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
<div className="text-muted-foreground mb-1">Version</div>
|
<div className="text-muted-foreground mb-1">{t('errorLogs.version')}</div>
|
||||||
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
<div className="font-medium">{parsed.version || na}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||||
<div className="text-muted-foreground mb-1">Endpoint</div>
|
<div className="text-muted-foreground mb-1">{t('errorLogs.endpoint')}</div>
|
||||||
<div className="font-medium truncate" title={parsed.endpoint}>
|
<div className="font-medium truncate" title={parsed.endpoint}>
|
||||||
{parsed.endpoint || 'N/A'}
|
{parsed.endpoint || na}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL */}
|
{/* URL */}
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="text-muted-foreground mb-1.5">URL</div>
|
<div className="text-muted-foreground mb-1.5">{t('errorLogs.url')}</div>
|
||||||
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
||||||
{parsed.url || 'N/A'}
|
{parsed.url || na}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
<div className="text-muted-foreground mb-1.5">{t('errorLogs.timestamp')}</div>
|
||||||
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
<div className="font-mono">{parsed.timestamp || na}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actionable suggestion based on error type */}
|
{/* Actionable suggestion based on error type */}
|
||||||
@@ -123,27 +126,14 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
|||||||
: 'text-blue-600 dark:text-blue-400'
|
: 'text-blue-600 dark:text-blue-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{parsed.errorType === 'rate_limit' && (
|
{parsed.errorType === 'rate_limit' &&
|
||||||
<>
|
(parsed.model
|
||||||
<strong>Rate Limited.</strong> Switch to a different account or wait for quota
|
? t('errorLogs.rateLimitedMsg', { model: parsed.model })
|
||||||
reset.
|
: t('errorLogs.rateLimitedMsgNoModel'))}
|
||||||
{parsed.model && (
|
{parsed.errorType === 'auth' && t('errorLogs.authFailedMsg')}
|
||||||
<>
|
{parsed.errorType === 'not_found' && t('errorLogs.notFoundMsg')}
|
||||||
{' '}
|
{parsed.errorType === 'server' && t('errorLogs.serverErrorMsg')}
|
||||||
Model <code className="font-mono text-[11px]">{parsed.model}</code> has
|
{parsed.errorType === 'timeout' && t('errorLogs.timeoutMsg')}
|
||||||
exhausted quota.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{parsed.errorType === 'auth' &&
|
|
||||||
'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'}
|
|
||||||
{parsed.errorType === 'not_found' &&
|
|
||||||
'Endpoint not found. This endpoint may not exist on this provider.'}
|
|
||||||
{parsed.errorType === 'server' &&
|
|
||||||
'Server error from upstream. Retry later or check provider status page.'}
|
|
||||||
{parsed.errorType === 'timeout' &&
|
|
||||||
'Request timed out. Check network connection or increase timeout settings.'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -178,8 +168,10 @@ export function HeadersTab({ headers }: { headers: Record<string, string> }) {
|
|||||||
|
|
||||||
/** JSON/Body tab content */
|
/** JSON/Body tab content */
|
||||||
export function BodyTab({ content, label }: { content: string; label: string }) {
|
export function BodyTab({ content, label }: { content: string; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!content || content.trim() === '') {
|
if (!content || content.trim() === '') {
|
||||||
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
|
const key = label === 'Request' ? 'errorLogs.noRequestBody' : 'errorLogs.noResponseBody';
|
||||||
|
return <div className="p-4 text-xs text-muted-foreground">{t(key)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to format as JSON
|
// Try to format as JSON
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useQuery, useIsMutating } from '@tanstack/react-query';
|
import { useQuery, useIsMutating } from '@tanstack/react-query';
|
||||||
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
useProxyStatus,
|
useProxyStatus,
|
||||||
useStartProxy,
|
useStartProxy,
|
||||||
@@ -89,15 +90,17 @@ function formatUptime(startedAt?: string): string {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeAgo(timestamp?: number): string {
|
function formatTimeAgo(
|
||||||
|
timestamp: number | undefined,
|
||||||
|
t: (key: string, opts?: { count?: number }) => string
|
||||||
|
): string {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const diff = Date.now() - timestamp;
|
const diff = Date.now() - timestamp;
|
||||||
const minutes = Math.floor(diff / (1000 * 60));
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
if (minutes < 1) return t('proxyStatusWidget.justNow');
|
||||||
if (minutes < 1) return 'just now';
|
if (minutes < 60) return t('proxyStatusWidget.minutesAgo', { count: minutes });
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
return t('proxyStatusWidget.hoursAgo', { count: hours });
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Icon button with tooltip wrapper */
|
/** Icon button with tooltip wrapper */
|
||||||
@@ -148,6 +151,7 @@ function IconButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProxyStatusWidget() {
|
export function ProxyStatusWidget() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: status, isLoading } = useProxyStatus();
|
const { data: status, isLoading } = useProxyStatus();
|
||||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
|
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
|
||||||
@@ -196,7 +200,9 @@ export function ProxyStatusWidget() {
|
|||||||
|
|
||||||
// Sync status
|
// Sync status
|
||||||
const isSyncConfigured = syncStatus?.configured ?? false;
|
const isSyncConfigured = syncStatus?.configured ?? false;
|
||||||
const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config';
|
const syncStatusText = isSyncConfigured
|
||||||
|
? t('proxyStatusWidget.syncReady')
|
||||||
|
: t('proxyStatusWidget.noConfig');
|
||||||
|
|
||||||
// Target version for update/downgrade badge
|
// Target version for update/downgrade badge
|
||||||
const targetVersion = isUnstable
|
const targetVersion = isUnstable
|
||||||
@@ -257,12 +263,12 @@ export function ProxyStatusWidget() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-4 h-4 text-blue-500" />
|
<Globe className="w-4 h-4 text-blue-500" />
|
||||||
<span className="text-sm font-medium">Remote Proxy</span>
|
<span className="text-sm font-medium">{t('proxyStatusWidget.remoteProxy')}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
>
|
>
|
||||||
Active
|
{t('proxyStatusWidget.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Activity className="w-3 h-3 text-blue-600" />
|
<Activity className="w-3 h-3 text-blue-600" />
|
||||||
@@ -273,7 +279,7 @@ export function ProxyStatusWidget() {
|
|||||||
<span className="font-mono">{remoteDisplayHost}</span>
|
<span className="font-mono">{remoteDisplayHost}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground/70 leading-tight">
|
<p className="text-[10px] text-muted-foreground/70 leading-tight">
|
||||||
Traffic auto-routed to remote server
|
{t('proxyStatusWidget.trafficAutoRouted')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,21 +316,21 @@ export function ProxyStatusWidget() {
|
|||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={isSyncing ? RefreshCw : FileDown}
|
icon={isSyncing ? RefreshCw : FileDown}
|
||||||
tooltip="Sync profiles to CLIProxy"
|
tooltip={t('proxyStatusWidget.tooltipSync')}
|
||||||
onClick={() => executeSync()}
|
onClick={() => executeSync()}
|
||||||
disabled={!isSyncConfigured || isActioning}
|
disabled={!isSyncConfigured || isActioning}
|
||||||
isPending={isSyncing}
|
isPending={isSyncing}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={RotateCw}
|
icon={RotateCw}
|
||||||
tooltip="Restart"
|
tooltip={t('proxyStatusWidget.tooltipRestart')}
|
||||||
onClick={() => restartProxy.mutate()}
|
onClick={() => restartProxy.mutate()}
|
||||||
disabled={isActioning}
|
disabled={isActioning}
|
||||||
isPending={restartProxy.isPending}
|
isPending={restartProxy.isPending}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={Square}
|
icon={Square}
|
||||||
tooltip="Stop"
|
tooltip={t('proxyStatusWidget.tooltipStop')}
|
||||||
onClick={() => stopProxy.mutate()}
|
onClick={() => stopProxy.mutate()}
|
||||||
disabled={isActioning}
|
disabled={isActioning}
|
||||||
isPending={stopProxy.isPending}
|
isPending={stopProxy.isPending}
|
||||||
@@ -335,7 +341,11 @@ export function ProxyStatusWidget() {
|
|||||||
{/* Settings button always visible */}
|
{/* Settings button always visible */}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={isExpanded ? X : Settings}
|
icon={isExpanded ? X : Settings}
|
||||||
tooltip={isExpanded ? 'Close' : 'Version settings'}
|
tooltip={
|
||||||
|
isExpanded
|
||||||
|
? t('proxyStatusWidget.tooltipClose')
|
||||||
|
: t('proxyStatusWidget.tooltipVersionSettings')
|
||||||
|
}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className={isExpanded ? 'bg-muted' : undefined}
|
className={isExpanded ? 'bg-muted' : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -363,7 +373,11 @@ export function ProxyStatusWidget() {
|
|||||||
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||||
)}
|
)}
|
||||||
onClick={() => handleInstallVersion(targetVersion)}
|
onClick={() => handleInstallVersion(targetVersion)}
|
||||||
title={`Click to ${isUnstable ? 'downgrade' : 'update'}`}
|
title={
|
||||||
|
isUnstable
|
||||||
|
? t('proxyStatusWidget.clickToDowngrade')
|
||||||
|
: t('proxyStatusWidget.clickToUpdate')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isUnstable ? (
|
{isUnstable ? (
|
||||||
<ArrowDown className="w-2.5 h-2.5" />
|
<ArrowDown className="w-2.5 h-2.5" />
|
||||||
@@ -379,11 +393,13 @@ export function ProxyStatusWidget() {
|
|||||||
{/* Stats row when running */}
|
{/* Stats row when running */}
|
||||||
{isRunning && status && (
|
{isRunning && status && (
|
||||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
<span className="flex items-center gap-1">
|
||||||
|
{t('proxyStatusWidget.port', { port: status.port })}
|
||||||
|
</span>
|
||||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="w-3 h-3" />
|
<Users className="w-3 h-3" />
|
||||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
{t('proxyStatusWidget.sessionCount', { count: status.sessionCount })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{status.startedAt && (
|
{status.startedAt && (
|
||||||
@@ -416,7 +432,9 @@ export function ProxyStatusWidget() {
|
|||||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||||
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
|
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4>
|
<h4 className="text-xs font-medium text-muted-foreground mb-3">
|
||||||
|
{t('proxyStatusWidget.versionManagement')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
{/* Version picker row */}
|
{/* Version picker row */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -427,7 +445,7 @@ export function ProxyStatusWidget() {
|
|||||||
disabled={versionsLoading}
|
disabled={versionsLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs flex-1">
|
<SelectTrigger className="h-8 text-xs flex-1">
|
||||||
<SelectValue placeholder="Select version to install..." />
|
<SelectValue placeholder={t('proxyStatusWidget.selectVersionPlaceholder')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{versionsData?.versions.slice(0, 20).map((v) => {
|
{versionsData?.versions.slice(0, 20).map((v) => {
|
||||||
@@ -439,7 +457,9 @@ export function ProxyStatusWidget() {
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
v{v}
|
v{v}
|
||||||
{v === versionsData.latestStable && (
|
{v === versionsData.latestStable && (
|
||||||
<span className="text-green-600 dark:text-green-400">(stable)</span>
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{t('proxyStatusWidget.stable')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{vIsUnstable && (
|
{vIsUnstable && (
|
||||||
<span className="text-amber-600 dark:text-amber-400">⚠</span>
|
<span className="text-amber-600 dark:text-amber-400">⚠</span>
|
||||||
@@ -464,7 +484,7 @@ export function ProxyStatusWidget() {
|
|||||||
) : (
|
) : (
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
Install
|
{t('proxyStatusWidget.install')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -474,14 +494,20 @@ export function ProxyStatusWidget() {
|
|||||||
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
|
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
|
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>Versions above {versionsData.maxStableVersion} have known issues</span>
|
<span>
|
||||||
|
{t('proxyStatusWidget.versionsAboveUnstable', {
|
||||||
|
version: versionsData.maxStableVersion,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sync time */}
|
{/* Sync time */}
|
||||||
{updateCheck?.checkedAt && (
|
{updateCheck?.checkedAt && (
|
||||||
<div className="mt-2 text-[10px] text-muted-foreground/60">
|
<div className="mt-2 text-[10px] text-muted-foreground/60">
|
||||||
Last checked {formatTimeAgo(updateCheck.checkedAt)}
|
{t('proxyStatusWidget.lastChecked', {
|
||||||
|
time: formatTimeAgo(updateCheck.checkedAt, t),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -490,7 +516,9 @@ export function ProxyStatusWidget() {
|
|||||||
{/* Not running state */}
|
{/* Not running state */}
|
||||||
{!isRunning && (
|
{!isRunning && (
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground">Not running</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('proxyStatusWidget.notRunning')}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -503,7 +531,7 @@ export function ProxyStatusWidget() {
|
|||||||
) : (
|
) : (
|
||||||
<Power className="w-3 h-3" />
|
<Power className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
Start
|
{t('proxyStatusWidget.start')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -514,27 +542,32 @@ export function ProxyStatusWidget() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
Install Unstable Version?
|
{t('proxyStatusWidget.installUnstableTitle')}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="space-y-2">
|
<AlertDialogDescription className="space-y-2">
|
||||||
<p>
|
<p
|
||||||
You are about to install <strong>v{pendingInstallVersion}</strong>, which is above
|
dangerouslySetInnerHTML={{
|
||||||
the maximum stable version{' '}
|
__html: t('proxyStatusWidget.installUnstableDesc', {
|
||||||
<strong>v{versionsData?.maxStableVersion || '6.6.80'}</strong>.
|
version: pendingInstallVersion ?? '',
|
||||||
</p>
|
maxStable: versionsData?.maxStableVersion || '6.6.80',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<p className="text-amber-600 dark:text-amber-400">
|
<p className="text-amber-600 dark:text-amber-400">
|
||||||
This version has known stability issues and may cause unexpected behavior.
|
{t('proxyStatusWidget.installUnstableWarning')}
|
||||||
</p>
|
</p>
|
||||||
<p>Are you sure you want to proceed?</p>
|
<p>{t('proxyStatusWidget.installUnstableConfirm')}</p>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={handleCancelUnstableInstall}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={handleCancelUnstableInstall}>
|
||||||
|
{t('proxyStatusWidget.cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleConfirmUnstableInstall}
|
onClick={handleConfirmUnstableInstall}
|
||||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||||
>
|
>
|
||||||
Install Anyway
|
{t('proxyStatusWidget.installAnyway')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { isSensitiveKey } from './utils';
|
import { isSensitiveKey } from './utils';
|
||||||
import type { Settings } from './types';
|
import type { Settings } from './types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface EnvEditorSectionProps {
|
interface EnvEditorSectionProps {
|
||||||
currentSettings: Settings | undefined;
|
currentSettings: Settings | undefined;
|
||||||
@@ -32,6 +33,7 @@ export function EnvEditorSection({
|
|||||||
onEnvValueChange,
|
onEnvValueChange,
|
||||||
onAddEnvVar,
|
onAddEnvVar,
|
||||||
}: EnvEditorSectionProps) {
|
}: EnvEditorSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Scrollable Environment Variables List */}
|
{/* Scrollable Environment Variables List */}
|
||||||
@@ -45,7 +47,7 @@ export function EnvEditorSection({
|
|||||||
{key}
|
{key}
|
||||||
{isSensitiveKey(key) && (
|
{isSensitiveKey(key) && (
|
||||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
||||||
sensitive
|
{t('envEditor.sensitive')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -67,10 +69,8 @@ export function EnvEditorSection({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
|
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
|
||||||
<p>No environment variables configured.</p>
|
<p>{t('envEditor.none')}</p>
|
||||||
<p className="text-xs mt-1 opacity-70">
|
<p className="text-xs mt-1 opacity-70">{t('envEditor.noneHint')}</p>
|
||||||
Add variables using the input below or edit the JSON directly.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -79,18 +79,18 @@ export function EnvEditorSection({
|
|||||||
{/* Fixed Add Input at Bottom */}
|
{/* Fixed Add Input at Bottom */}
|
||||||
<div className="p-4 border-t bg-background shrink-0">
|
<div className="p-4 border-t bg-background shrink-0">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
Add Environment Variable
|
{t('envEditor.addVariable')}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="VARIABLE_NAME"
|
placeholder={t('envEditor.keyPlaceholder')}
|
||||||
value={newEnvKey}
|
value={newEnvKey}
|
||||||
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
|
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
|
||||||
className="font-mono text-sm h-8 w-2/5"
|
className="font-mono text-sm h-8 w-2/5"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
|
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="value"
|
placeholder={t('envEditor.valuePlaceholder')}
|
||||||
value={newEnvValue}
|
value={newEnvValue}
|
||||||
onChange={(e) => onNewEnvValueChange(e.target.value)}
|
onChange={(e) => onNewEnvValueChange(e.target.value)}
|
||||||
className="font-mono text-sm h-8 flex-1"
|
className="font-mono text-sm h-8 flex-1"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ChevronRight, Settings2, Plus } from 'lucide-react';
|
|||||||
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
|
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
import type { Settings, SettingsResponse } from './types';
|
import type { Settings, SettingsResponse } from './types';
|
||||||
import type { CliTarget } from '@/lib/api-client';
|
import type { CliTarget } from '@/lib/api-client';
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export function FriendlyUISection({
|
|||||||
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
|
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
|
||||||
}
|
}
|
||||||
// Show feedback toast
|
// Show feedback toast
|
||||||
toast.success('Applied model to all tiers', { duration: 2000 });
|
toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle tier mapping change
|
// Handle tier mapping change
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
|
|||||||
import { RawEditorSection } from './raw-editor-section';
|
import { RawEditorSection } from './raw-editor-section';
|
||||||
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
|
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
|
||||||
import { api, type CliTarget } from '@/lib/api-client';
|
import { api, type CliTarget } from '@/lib/api-client';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
|
||||||
export function ProfileEditor({
|
export function ProfileEditor({
|
||||||
profileName,
|
profileName,
|
||||||
@@ -143,7 +144,7 @@ export function ProfileEditor({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
setLocalEdits({});
|
setLocalEdits({});
|
||||||
setRawJsonEdits(null);
|
setRawJsonEdits(null);
|
||||||
toast.success('Settings saved');
|
toast.success(i18n.t('commonToast.settingsSaved'));
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
if (error.message === 'CONFLICT') setConflictDialog(true);
|
if (error.message === 'CONFLICT') setConflictDialog(true);
|
||||||
@@ -158,12 +159,12 @@ export function ProfileEditor({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
toast.success('Default target updated');
|
toast.success(i18n.t('commonToast.defaultTargetUpdated'));
|
||||||
},
|
},
|
||||||
onError: (error: Error, target: CliTarget) => {
|
onError: (error: Error, target: CliTarget) => {
|
||||||
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
|
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
|
||||||
const suffix = error.message.trim() ? `: ${error.message}` : '';
|
const suffix = error.message.trim() ? `: ${error.message}` : '';
|
||||||
toast.error(`Failed to update default target to ${targetLabel}${suffix}`);
|
toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Displays profile information and usage commands
|
* Displays profile information and usage commands
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
@@ -17,6 +18,7 @@ interface InfoSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isDroidTarget = target === 'droid';
|
const isDroidTarget = target === 'droid';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||||
<Info className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
Profile Information
|
{t('profileEditor.profileInfo')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">Profile Name</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('profileEditor.profileName')}
|
||||||
|
</span>
|
||||||
<span className="font-mono">{data.profile}</span>
|
<span className="font-mono">{data.profile}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">File Path</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('profileEditor.filePath')}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||||
{data.path}
|
{data.path}
|
||||||
@@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">Last Modified</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('profileEditor.lastModified')}
|
||||||
|
</span>
|
||||||
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
|
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">Default Target</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('profileEditor.defaultTarget')}
|
||||||
|
</span>
|
||||||
<span className="font-mono">{target}</span>
|
<span className="font-mono">{target}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
|
|
||||||
{/* Usage */}
|
{/* Usage */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
<h3 className="text-sm font-medium mb-3">{t('profileEditor.quickUsage')}</h3>
|
||||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Run with profile</Label>
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{t('profileEditor.runWithProfile')}
|
||||||
|
</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
ccs {profileName} "prompt"
|
ccs {profileName} "prompt"
|
||||||
@@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
|
{isDroidTarget
|
||||||
|
? t('profileEditor.droidAliasExplicit')
|
||||||
|
: t('profileEditor.runOnDroid')}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
@@ -93,7 +107,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'}
|
{isDroidTarget
|
||||||
|
? t('profileEditor.overrideToClaude')
|
||||||
|
: t('profileEditor.overrideToClaudeExplicit')}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
@@ -107,7 +123,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Set as default</Label>
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{t('profileEditor.setAsDefault')}
|
||||||
|
</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
ccs default {profileName}
|
ccs default {profileName}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||||
import { suggestTierMappings } from '@/lib/openrouter-utils';
|
import { suggestTierMappings } from '@/lib/openrouter-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface TierMapping {
|
export interface TierMapping {
|
||||||
opus?: string;
|
opus?: string;
|
||||||
@@ -32,6 +33,7 @@ export function ModelTierMapping({
|
|||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
}: ModelTierMappingProps) {
|
}: ModelTierMappingProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { models } = useOpenRouterCatalog();
|
const { models } = useOpenRouterCatalog();
|
||||||
|
|
||||||
const suggestions = useMemo(() => {
|
const suggestions = useMemo(() => {
|
||||||
@@ -53,18 +55,18 @@ export function ModelTierMapping({
|
|||||||
<Collapsible className={cn('group', className)}>
|
<Collapsible className={cn('group', className)}>
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
|
||||||
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||||
Model Tier Mapping
|
{t('modelTierMapping.title')}
|
||||||
<span className="text-muted-foreground font-normal">(Advanced)</span>
|
<span className="text-muted-foreground font-normal">
|
||||||
|
({t('modelTierMapping.advanced')})
|
||||||
|
</span>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-3">
|
<CollapsibleContent className="space-y-3 pt-3">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">{t('modelTierMapping.description')}</p>
|
||||||
Configure different models for Claude Code's opus/sonnet/haiku tiers.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{hasSuggestions && (
|
{hasSuggestions && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
|
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
|
||||||
<Wand2 className="mr-1 h-4 w-4" />
|
<Wand2 className="mr-1 h-4 w-4" />
|
||||||
Auto-suggest based on {selectedModel?.split('/')[0]}
|
{t('modelTierMapping.autoSuggest', { provider: selectedModel?.split('/')[0] })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export function ModelTierMapping({
|
|||||||
id="tier-opus"
|
id="tier-opus"
|
||||||
value={value.opus ?? ''}
|
value={value.opus ?? ''}
|
||||||
onChange={(e) => updateTier('opus', e.target.value)}
|
onChange={(e) => updateTier('opus', e.target.value)}
|
||||||
placeholder="e.g., anthropic/claude-opus-4"
|
placeholder={t('modelTierMapping.opusPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
@@ -88,7 +90,7 @@ export function ModelTierMapping({
|
|||||||
id="tier-sonnet"
|
id="tier-sonnet"
|
||||||
value={value.sonnet ?? ''}
|
value={value.sonnet ?? ''}
|
||||||
onChange={(e) => updateTier('sonnet', e.target.value)}
|
onChange={(e) => updateTier('sonnet', e.target.value)}
|
||||||
placeholder="e.g., anthropic/claude-sonnet-4"
|
placeholder={t('modelTierMapping.sonnetPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
@@ -99,15 +101,12 @@ export function ModelTierMapping({
|
|||||||
id="tier-haiku"
|
id="tier-haiku"
|
||||||
value={value.haiku ?? ''}
|
value={value.haiku ?? ''}
|
||||||
onChange={(e) => updateTier('haiku', e.target.value)}
|
onChange={(e) => updateTier('haiku', e.target.value)}
|
||||||
placeholder="e.g., anthropic/claude-3.5-haiku"
|
placeholder={t('modelTierMapping.haikuPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">{t('modelTierMapping.footer')}</p>
|
||||||
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL.
|
|
||||||
</p>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||||
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
|
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface OpenRouterQuickStartProps {
|
interface OpenRouterQuickStartProps {
|
||||||
onOpenRouterClick: () => void;
|
onOpenRouterClick: () => void;
|
||||||
@@ -19,6 +20,7 @@ export function OpenRouterQuickStart({
|
|||||||
onOpenRouterClick,
|
onOpenRouterClick,
|
||||||
onCustomClick,
|
onCustomClick,
|
||||||
}: OpenRouterQuickStartProps) {
|
}: OpenRouterQuickStartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { modelCount, isLoading } = useOpenRouterReady();
|
const { modelCount, isLoading } = useOpenRouterReady();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,13 +37,14 @@ export function OpenRouterQuickStart({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
|
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
|
||||||
>
|
>
|
||||||
Recommended
|
{t('openrouterQuickStart.recommended')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">Start with OpenRouter</CardTitle>
|
<CardTitle className="text-xl">{t('openrouterQuickStart.title')}</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
|
{t('openrouterQuickStart.description', {
|
||||||
Meta and more - all through one API.
|
modelCountLabel: isLoading ? '300+' : `${modelCount}+`,
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -49,11 +52,11 @@ export function OpenRouterQuickStart({
|
|||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Zap className="w-4 h-4 text-accent" />
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
<span>One API, all providers</span>
|
<span>{t('openrouterQuickStart.featureOneApi')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Sparkles className="w-4 h-4 text-accent" />
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
<span>Model tier mapping</span>
|
<span>{t('openrouterQuickStart.featureTierMapping')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,12 +65,12 @@ export function OpenRouterQuickStart({
|
|||||||
className="w-full bg-accent hover:bg-accent/90 text-white"
|
className="w-full bg-accent hover:bg-accent/90 text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Create OpenRouter Profile
|
{t('openrouterQuickStart.createOpenRouterProfile')}
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
Get your API key at{' '}
|
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||||
<a
|
<a
|
||||||
href="https://openrouter.ai/keys"
|
href="https://openrouter.ai/keys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -84,13 +87,13 @@ export function OpenRouterQuickStart({
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
<span className="text-xs text-muted-foreground">or</span>
|
<span className="text-xs text-muted-foreground">{t('openrouterQuickStart.or')}</span>
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Option */}
|
{/* Custom Option */}
|
||||||
<Button variant="outline" onClick={onCustomClick} className="w-full">
|
<Button variant="outline" onClick={onCustomClick} className="w-full">
|
||||||
Create Custom API Profile
|
{t('openrouterQuickStart.createCustomProfile')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||||
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
|
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
} from '@/lib/openrouter-utils';
|
} from '@/lib/openrouter-utils';
|
||||||
import type { CategorizedModel } from '@/lib/openrouter-types';
|
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||||
import type { CliTarget } from '@/lib/api-client';
|
import type { CliTarget } from '@/lib/api-client';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -104,6 +106,7 @@ export function ProfileCreateDialog({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
initialMode = 'openrouter',
|
initialMode = 'openrouter',
|
||||||
}: ProfileCreateDialogProps) {
|
}: ProfileCreateDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const createMutation = useCreateProfile();
|
const createMutation = useCreateProfile();
|
||||||
const [activeTab, setActiveTab] = useState('basic');
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||||
@@ -242,7 +245,7 @@ export function ProfileCreateDialog({
|
|||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
// Validate API key - required unless preset has requiresApiKey: false
|
// Validate API key - required unless preset has requiresApiKey: false
|
||||||
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
||||||
toast.error('API key is required');
|
toast.error(i18n.t('commonToast.apiKeyRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use user-provided baseUrl (allows customization of preset URLs)
|
// Use user-provided baseUrl (allows customization of preset URLs)
|
||||||
@@ -290,7 +293,9 @@ export function ProfileCreateDialog({
|
|||||||
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
|
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
|
||||||
{/* Main Options: OpenRouter + Custom */}
|
{/* Main Options: OpenRouter + Custom */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
|
<Label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
{t('profileEditor.provider')}
|
||||||
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{RECOMMENDED_PRESETS.map((preset) => (
|
{RECOMMENDED_PRESETS.map((preset) => (
|
||||||
<CompactPresetCard
|
<CompactPresetCard
|
||||||
@@ -312,7 +317,7 @@ export function ProfileCreateDialog({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4" />
|
<Settings2 className="w-4 h-4" />
|
||||||
<span>Custom</span>
|
<span>{t('profileEditor.custom')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles';
|
import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
|
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ interface ProfileDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const createMutation = useCreateProfile();
|
const createMutation = useCreateProfile();
|
||||||
const updateMutation = useUpdateProfile();
|
const updateMutation = useUpdateProfile();
|
||||||
const [showModelMapping, setShowModelMapping] = useState(false);
|
const [showModelMapping, setShowModelMapping] = useState(false);
|
||||||
@@ -115,34 +117,45 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{profile ? t('profileDialog.editTitle') : t('profileDialog.createTitle')}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{t('profileDialog.name')}</Label>
|
||||||
<Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} />
|
<Input
|
||||||
|
id="name"
|
||||||
|
{...register('name')}
|
||||||
|
placeholder={t('profileDialog.namePlaceholder')}
|
||||||
|
disabled={!!profile}
|
||||||
|
/>
|
||||||
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
|
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="baseUrl">Base URL</Label>
|
<Label htmlFor="baseUrl">{t('profileDialog.baseUrl')}</Label>
|
||||||
<Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" />
|
<Input
|
||||||
|
id="baseUrl"
|
||||||
|
{...register('baseUrl')}
|
||||||
|
placeholder={t('profileDialog.baseUrlPlaceholder')}
|
||||||
|
/>
|
||||||
{errors.baseUrl && (
|
{errors.baseUrl && (
|
||||||
<span className="text-xs text-red-500">{errors.baseUrl.message}</span>
|
<span className="text-xs text-red-500">{errors.baseUrl.message}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="apiKey">API Key</Label>
|
<Label htmlFor="apiKey">{t('profileDialog.apiKey')}</Label>
|
||||||
<Input id="apiKey" type="password" {...register('apiKey')} />
|
<Input id="apiKey" type="password" {...register('apiKey')} />
|
||||||
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
|
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="model">Default Model (ANTHROPIC_MODEL)</Label>
|
<Label htmlFor="model">{t('profileDialog.defaultModel')}</Label>
|
||||||
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
|
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Leave blank to use: {DEFAULT_MODEL}
|
{t('profileDialog.defaultModelHint', { model: DEFAULT_MODEL })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,7 +166,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => setShowModelMapping(!showModelMapping)}
|
onClick={() => setShowModelMapping(!showModelMapping)}
|
||||||
>
|
>
|
||||||
<span>Model Mapping (Opus/Sonnet/Haiku)</span>
|
<span>{t('profileDialog.modelMappingTitle')}</span>
|
||||||
{showModelMapping ? (
|
{showModelMapping ? (
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
@@ -164,13 +177,12 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
{showModelMapping && (
|
{showModelMapping && (
|
||||||
<div className="p-3 pt-0 space-y-3 border-t">
|
<div className="p-3 pt-0 space-y-3 border-t">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Configure different model IDs for each tier. Useful for API proxies that route
|
{t('profileDialog.modelMappingDesc')}
|
||||||
different model types to different backends.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="opusModel" className="text-xs">
|
<Label htmlFor="opusModel" className="text-xs">
|
||||||
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)
|
{t('profileDialog.opusModel')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="opusModel"
|
id="opusModel"
|
||||||
@@ -182,7 +194,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sonnetModel" className="text-xs">
|
<Label htmlFor="sonnetModel" className="text-xs">
|
||||||
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)
|
{t('profileDialog.sonnetModel')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="sonnetModel"
|
id="sonnetModel"
|
||||||
@@ -194,7 +206,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="haikuModel" className="text-xs">
|
<Label htmlFor="haikuModel" className="text-xs">
|
||||||
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)
|
{t('profileDialog.haikuModel')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="haikuModel"
|
id="haikuModel"
|
||||||
@@ -209,14 +221,14 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{t('profileDialog.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||||
{createMutation.isPending || updateMutation.isPending
|
{createMutation.isPending || updateMutation.isPending
|
||||||
? 'Saving...'
|
? t('profileDialog.saving')
|
||||||
: profile
|
: profile
|
||||||
? 'Update'
|
? t('profileDialog.update')
|
||||||
: 'Create'}
|
: t('profileDialog.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
if (result.success && result.presetName) {
|
if (result.success && result.presetName) {
|
||||||
toast.success(`Applied "${result.presetName}" preset`);
|
toast.success(`Applied "${result.presetName}" preset`);
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
toast.warning('Account added, but failed to apply default preset');
|
toast.warning(i18n.t('commonToast.accountAddedPresetFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||||
import type { VariantStepProps } from '../types';
|
import type { VariantStepProps } from '../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const CUSTOM_MODEL_VALUE = '__custom__';
|
const CUSTOM_MODEL_VALUE = '__custom__';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export function VariantStep({
|
|||||||
onSkip,
|
onSkip,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: VariantStepProps) {
|
}: VariantStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Track if user selected custom model option
|
// Track if user selected custom model option
|
||||||
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
|
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
|
||||||
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
||||||
@@ -55,7 +57,7 @@ export function VariantStep({
|
|||||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
Using:{' '}
|
{t('setupVariant.using')}{' '}
|
||||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
{selectedAccount.email || selectedAccount.id}
|
{selectedAccount.email || selectedAccount.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -64,33 +66,33 @@ export function VariantStep({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="variant-name">Variant Name *</Label>
|
<Label htmlFor="variant-name">{t('setupVariant.variantNameRequired')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="variant-name"
|
id="variant-name"
|
||||||
value={variantName}
|
value={variantName}
|
||||||
onChange={(e) => onVariantNameChange(e.target.value)}
|
onChange={(e) => onVariantNameChange(e.target.value)}
|
||||||
placeholder="e.g., my-gemini, g3, flash"
|
placeholder={t('setupVariant.variantNamePlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Use this name to invoke: ccs {variantName || '<name>'} "prompt"
|
{t('setupVariant.invokeHintPrefix')} ccs {variantName || '<name>'} "prompt"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Model</Label>
|
<Label>{t('setupVariant.model')}</Label>
|
||||||
{showCustomInput ? (
|
{showCustomInput ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
value={modelName}
|
value={modelName}
|
||||||
onChange={(e) => onModelChange(e.target.value)}
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
placeholder="e.g., gemini-2.5-pro, claude-opus-4-5"
|
placeholder={t('setupVariant.modelPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-primary hover:underline"
|
className="text-xs text-primary hover:underline"
|
||||||
onClick={() => setShowCustomInput(false)}
|
onClick={() => setShowCustomInput(false)}
|
||||||
>
|
>
|
||||||
Choose from presets instead
|
{t('setupVariant.choosePresetInstead')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -99,7 +101,7 @@ export function VariantStep({
|
|||||||
onValueChange={handleModelSelect}
|
onValueChange={handleModelSelect}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a model" />
|
<SelectValue placeholder={t('setupVariant.selectModel')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{catalogModels.map((m) => (
|
{catalogModels.map((m) => (
|
||||||
@@ -113,35 +115,37 @@ export function VariantStep({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value={CUSTOM_MODEL_VALUE}>
|
<SelectItem value={CUSTOM_MODEL_VALUE}>
|
||||||
<span className="text-primary">Custom model name...</span>
|
<span className="text-primary">{t('setupVariant.customModelName')}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{showCustomInput
|
{showCustomInput
|
||||||
? 'Enter any model name supported by CLIProxy'
|
? t('setupVariant.enterAnyModel')
|
||||||
: `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`}
|
: t('setupVariant.defaultModel', {
|
||||||
|
model:
|
||||||
|
MODEL_CATALOGS[selectedProvider]?.defaultModel ||
|
||||||
|
t('setupVariant.providerDefault'),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button variant="ghost" onClick={onBack}>
|
<Button variant="ghost" onClick={onBack}>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Back
|
{t('setupVariant.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" onClick={onSkip}>
|
<Button variant="ghost" onClick={onSkip}>
|
||||||
Skip
|
{t('setupVariant.skip')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onCreate} disabled={!variantName || isPending}>
|
<Button onClick={onCreate} disabled={!variantName || isPending}>
|
||||||
{isPending ? 'Creating...' : 'Create Variant'}
|
{isPending ? t('setupVariant.creating') : t('setupVariant.createVariant')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
<p className="text-xs text-center text-muted-foreground">{t('setupVariant.skipHint')}</p>
|
||||||
Skip if you just wanted to add an account without creating a variant
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getDeviceCodeProviderDisplayName,
|
getDeviceCodeProviderDisplayName,
|
||||||
getDeviceCodeProviderInstruction,
|
getDeviceCodeProviderInstruction,
|
||||||
} from '@/lib/provider-config';
|
} from '@/lib/provider-config';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
|
||||||
interface DeviceCodeDialogProps {
|
interface DeviceCodeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -72,10 +73,10 @@ export function DeviceCodeDialog({
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(userCode);
|
await navigator.clipboard.writeText(userCode);
|
||||||
setHasCopied(true);
|
setHasCopied(true);
|
||||||
toast.success('Code copied to clipboard');
|
toast.success(i18n.t('commonToast.codeCopied'));
|
||||||
setTimeout(() => setHasCopied(false), 2000);
|
setTimeout(() => setHasCopied(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to copy code');
|
toast.error(i18n.t('commonToast.copyCodeFailed'));
|
||||||
}
|
}
|
||||||
}, [userCode]);
|
}, [userCode]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
|
|||||||
import { ConfirmDialog } from './confirm-dialog';
|
import { ConfirmDialog } from './confirm-dialog';
|
||||||
import { Save, X, Loader2, Code2 } from 'lucide-react';
|
import { Save, X, Loader2, Code2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
|
||||||
// Lazy load CodeEditor to reduce initial bundle size
|
// Lazy load CodeEditor to reduce initial bundle size
|
||||||
const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
|
const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
|
||||||
@@ -154,7 +155,7 @@ function SettingsDialogContent({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
toast.success('Settings saved');
|
toast.success(i18n.t('commonToast.settingsSaved'));
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type NoticeProgressState } from '@/lib/updates-notice-state';
|
import { type NoticeProgressState } from '@/lib/updates-notice-state';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
const NOTICE_PROGRESS_META: Record<
|
|
||||||
NoticeProgressState,
|
|
||||||
{ label: string; className: string; showDot?: boolean }
|
|
||||||
> = {
|
|
||||||
new: {
|
|
||||||
label: 'Needs Action',
|
|
||||||
className:
|
|
||||||
'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300',
|
|
||||||
showDot: true,
|
|
||||||
},
|
|
||||||
seen: {
|
|
||||||
label: 'In Review',
|
|
||||||
className:
|
|
||||||
'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300',
|
|
||||||
},
|
|
||||||
done: {
|
|
||||||
label: 'Done',
|
|
||||||
className:
|
|
||||||
'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300',
|
|
||||||
},
|
|
||||||
dismissed: {
|
|
||||||
label: 'Dismissed',
|
|
||||||
className:
|
|
||||||
'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NoticeProgressBadge({
|
export function NoticeProgressBadge({
|
||||||
state,
|
state,
|
||||||
@@ -36,7 +10,30 @@ export function NoticeProgressBadge({
|
|||||||
state: NoticeProgressState;
|
state: NoticeProgressState;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const meta = NOTICE_PROGRESS_META[state];
|
const { t } = useTranslation();
|
||||||
|
const meta = {
|
||||||
|
new: {
|
||||||
|
label: t('updates.progressNeedsAction'),
|
||||||
|
className:
|
||||||
|
'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300',
|
||||||
|
showDot: true,
|
||||||
|
},
|
||||||
|
seen: {
|
||||||
|
label: t('updates.progressInReview'),
|
||||||
|
className:
|
||||||
|
'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
label: t('updates.progressDone'),
|
||||||
|
className:
|
||||||
|
'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||||
|
},
|
||||||
|
dismissed: {
|
||||||
|
label: t('updates.progressDismissed'),
|
||||||
|
className:
|
||||||
|
'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30',
|
||||||
|
},
|
||||||
|
}[state] as { label: string; className: string; showDot?: boolean };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import {
|
|||||||
type SupportScope,
|
type SupportScope,
|
||||||
} from '@/lib/support-updates-catalog';
|
} from '@/lib/support-updates-catalog';
|
||||||
import { SupportStatusBadge } from './support-status-badge';
|
import { SupportStatusBadge } from './support-status-badge';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
const PILLAR_LABELS: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
|
|
||||||
{ key: 'baseUrl', label: 'Base URL' },
|
|
||||||
{ key: 'auth', label: 'Auth' },
|
|
||||||
{ key: 'model', label: 'Model' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SCOPE_STYLES: Record<SupportScope, string> = {
|
const SCOPE_STYLES: Record<SupportScope, string> = {
|
||||||
target:
|
target:
|
||||||
@@ -27,6 +22,13 @@ const SCOPE_STYLES: Record<SupportScope, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
|
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
|
||||||
|
{ key: 'baseUrl', label: 'Base URL' },
|
||||||
|
{ key: 'auth', label: 'Auth' },
|
||||||
|
{ key: 'model', label: 'Model' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full gap-4">
|
<Card className="h-full gap-4">
|
||||||
<CardHeader className="gap-3">
|
<CardHeader className="gap-3">
|
||||||
@@ -45,7 +47,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
|
|||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
{PILLAR_LABELS.map((pillar) => (
|
{pillarLabels.map((pillar) => (
|
||||||
<div key={pillar.key} className="rounded-md border bg-muted/30 p-2">
|
<div key={pillar.key} className="rounded-md border bg-muted/30 p-2">
|
||||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{pillar.label}
|
{pillar.label}
|
||||||
@@ -57,7 +59,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Dashboard
|
{t('updates.dashboard')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{entry.routes.map((route) => (
|
{entry.routes.map((route) => (
|
||||||
@@ -75,7 +77,7 @@ export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
CLI Usage
|
{t('updates.cliUsage')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{entry.commands.slice(0, 2).map((command) => (
|
{entry.commands.slice(0, 2).map((command) => (
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { SupportStatus } from '@/lib/support-updates-catalog';
|
import type { SupportStatus } from '@/lib/support-updates-catalog';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
const STATUS_LABELS: Record<SupportStatus, string> = {
|
|
||||||
new: 'New',
|
|
||||||
stable: 'Stable',
|
|
||||||
planned: 'Planned',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_STYLES: Record<SupportStatus, string> = {
|
const STATUS_STYLES: Record<SupportStatus, string> = {
|
||||||
new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300',
|
new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300',
|
||||||
@@ -23,9 +18,16 @@ export function SupportStatusBadge({
|
|||||||
status: SupportStatus;
|
status: SupportStatus;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labels: Record<SupportStatus, string> = {
|
||||||
|
new: t('updates.statusNew'),
|
||||||
|
stable: t('updates.statusStable'),
|
||||||
|
planned: t('updates.statusPlanned'),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}>
|
<Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}>
|
||||||
{STATUS_LABELS[status]}
|
{labels[status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type SupportNotice,
|
type SupportNotice,
|
||||||
} from '@/lib/support-updates-catalog';
|
} from '@/lib/support-updates-catalog';
|
||||||
import { type NoticeProgressState } from '@/lib/updates-notice-state';
|
import { type NoticeProgressState } from '@/lib/updates-notice-state';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed';
|
type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed';
|
||||||
|
|
||||||
@@ -29,10 +30,11 @@ export function UpdatesDetailsPanel({
|
|||||||
relatedEntries: CliSupportEntry[];
|
relatedEntries: CliSupportEntry[];
|
||||||
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
|
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!notice) {
|
if (!notice) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
|
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
|
||||||
<p className="text-sm text-muted-foreground">No updates available.</p>
|
<p className="text-sm text-muted-foreground">{t('updates.noUpdates')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,21 +55,21 @@ export function UpdatesDetailsPanel({
|
|||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<CalendarClock className="h-3.5 w-3.5" />
|
<CalendarClock className="h-3.5 w-3.5" />
|
||||||
<span>Published {formatCatalogDate(notice.publishedAt)}</span>
|
<span>{t('updates.published', { date: formatCatalogDate(notice.publishedAt) })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={() => onUpdateProgress('done')}>
|
<Button size="sm" onClick={() => onUpdateProgress('done')}>
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
Mark done
|
{t('updates.markDone')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
|
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
Dismiss
|
{t('updates.dismiss')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
|
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Reopen
|
{t('updates.reopen')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +80,7 @@ export function UpdatesDetailsPanel({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
<CardTitle className="text-base">Do Next</CardTitle>
|
<CardTitle className="text-base">{t('updates.doNext')}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{notice.primaryAction}</CardDescription>
|
<CardDescription>{notice.primaryAction}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -96,8 +98,8 @@ export function UpdatesDetailsPanel({
|
|||||||
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
|
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Impacted Integrations</CardTitle>
|
<CardTitle className="text-base">{t('updates.impactedIntegrations')}</CardTitle>
|
||||||
<CardDescription>Related areas based on update scope and routing.</CardDescription>
|
<CardDescription>{t('updates.impactedDesc')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0">
|
<CardContent className="min-h-0">
|
||||||
<ScrollArea className="h-full pr-2">
|
<ScrollArea className="h-full pr-2">
|
||||||
@@ -134,10 +136,8 @@ export function UpdatesDetailsPanel({
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Why It Matters</CardTitle>
|
<CardTitle className="text-base">{t('updates.whyMatters')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('updates.whyMattersDesc')}</CardDescription>
|
||||||
Short context only, no wall-of-text release notes.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0">
|
<CardContent className="min-h-0">
|
||||||
<ScrollArea className="h-full pr-2">
|
<ScrollArea className="h-full pr-2">
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { ArrowUpRight, Terminal } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
|
import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
|
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -16,7 +18,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction
|
|||||||
{action.type === 'route' && action.path && (
|
{action.type === 'route' && action.path && (
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
<Link to={action.path}>
|
<Link to={action.path}>
|
||||||
Open
|
{t('updates.open')}
|
||||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getFormattingLocale } from '@/lib/locales';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error Log Parser Utility
|
* Error Log Parser Utility
|
||||||
*
|
*
|
||||||
@@ -394,7 +396,9 @@ function getStatusText(code: number): string {
|
|||||||
/**
|
/**
|
||||||
* Format Unix timestamp (seconds) to relative time string
|
* Format Unix timestamp (seconds) to relative time string
|
||||||
*/
|
*/
|
||||||
export function formatRelativeTime(modifiedSeconds: number): string {
|
export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
|
||||||
|
const formatLocale = getFormattingLocale(locale);
|
||||||
|
const isZh = formatLocale === 'zh-CN';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
||||||
const diff = now - modified;
|
const diff = now - modified;
|
||||||
@@ -404,14 +408,14 @@ export function formatRelativeTime(modifiedSeconds: number): string {
|
|||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
if (seconds < 60) return 'just now';
|
if (seconds < 60) return isZh ? '刚刚' : 'just now';
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
|
||||||
if (days < 7) return `${days}d ago`;
|
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
|
||||||
|
|
||||||
// Format as date for older logs
|
// Format as date for older logs
|
||||||
const date = new Date(modified);
|
const date = new Date(modified);
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString(formatLocale, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,16 +440,26 @@ export function getStatusColor(code: number): string {
|
|||||||
/**
|
/**
|
||||||
* Get error type label
|
* Get error type label
|
||||||
*/
|
*/
|
||||||
export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
|
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
|
||||||
const labels: Record<string, string> = {
|
const isZh = getFormattingLocale(locale) === 'zh-CN';
|
||||||
rate_limit: 'Rate Limited',
|
const labels: Record<string, string> = isZh
|
||||||
auth: 'Auth Error',
|
? {
|
||||||
not_found: 'Not Found',
|
rate_limit: '限流',
|
||||||
server: 'Server Error',
|
auth: '认证错误',
|
||||||
timeout: 'Timeout',
|
not_found: '未找到',
|
||||||
unknown: 'Error',
|
server: '服务错误',
|
||||||
};
|
timeout: '超时',
|
||||||
return labels[type] || 'Error';
|
unknown: '错误',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
rate_limit: 'Rate Limited',
|
||||||
|
auth: 'Auth Error',
|
||||||
|
not_found: 'Not Found',
|
||||||
|
server: 'Server Error',
|
||||||
|
timeout: 'Timeout',
|
||||||
|
unknown: 'Error',
|
||||||
|
};
|
||||||
|
return labels[type] || (isZh ? '错误' : 'Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,22 +481,27 @@ export function formatQuotaResetDelay(seconds: number | null): string | null {
|
|||||||
/**
|
/**
|
||||||
* Format quota reset timestamp as relative time
|
* Format quota reset timestamp as relative time
|
||||||
*/
|
*/
|
||||||
export function formatQuotaResetTimestamp(timestamp: string | null): string | null {
|
export function formatQuotaResetTimestamp(
|
||||||
|
timestamp: string | null,
|
||||||
|
locale?: string
|
||||||
|
): string | null {
|
||||||
if (!timestamp) return null;
|
if (!timestamp) return null;
|
||||||
|
const isZh = getFormattingLocale(locale) === 'zh-CN';
|
||||||
try {
|
try {
|
||||||
const resetDate = new Date(timestamp);
|
const resetDate = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = resetDate.getTime() - now.getTime();
|
const diff = resetDate.getTime() - now.getTime();
|
||||||
if (diff <= 0) return 'now';
|
if (diff <= 0) return isZh ? '现在' : 'now';
|
||||||
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
const seconds = Math.floor(diff / 1000);
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return isZh ? `${seconds}秒` : `${seconds}s`;
|
||||||
if (seconds < 3600) {
|
if (seconds < 3600) {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
return `${mins}m`;
|
return isZh ? `${mins}分钟` : `${mins}m`;
|
||||||
}
|
}
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
|
||||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+2237
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
|||||||
|
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const;
|
||||||
|
|
||||||
|
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
export function isSupportedLocale(locale: string): locale is AppLocale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale as AppLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocale(locale: string | null | undefined): AppLocale {
|
||||||
|
if (!locale) return 'en';
|
||||||
|
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredLocale(): AppLocale | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
return raw ? normalizeLocale(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitialLocale(): AppLocale {
|
||||||
|
const stored = getStoredLocale();
|
||||||
|
if (stored) return stored;
|
||||||
|
if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language);
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormattingLocale(locale?: string): string {
|
||||||
|
if (locale) return normalizeLocale(locale);
|
||||||
|
const stored = getStoredLocale();
|
||||||
|
if (stored) return stored;
|
||||||
|
if (typeof navigator !== 'undefined') return normalizeLocale(navigator.language);
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistLocale(locale: string): AppLocale {
|
||||||
|
const normalized = normalizeLocale(locale);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, normalized);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors and keep runtime locale only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
+59
-51
@@ -17,8 +17,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
|
import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading } = useAccounts();
|
const { data, isLoading } = useAccounts();
|
||||||
const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
|
const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
|
||||||
@@ -59,12 +61,12 @@ export function AccountsPage() {
|
|||||||
<div className="p-4 border-b bg-background space-y-2">
|
<div className="p-4 border-b bg-background space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="h-5 w-5 text-primary" />
|
||||||
<h1 className="font-semibold">Accounts</h1>
|
<h1 className="font-semibold">{t('accountsPage.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Manage
|
{t('accountsPage.managePrefix')}
|
||||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||||
accounts and pool onboarding from one panel.
|
{t('accountsPage.manageSuffix')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export function AccountsPage() {
|
|||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Primary Actions
|
{t('accountsPage.primaryActions')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -80,7 +82,7 @@ export function AccountsPage() {
|
|||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Account
|
{t('accountsPage.createAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -88,7 +90,7 @@ export function AccountsPage() {
|
|||||||
onClick={handleOpenClaudePoolAuth}
|
onClick={handleOpenClaudePoolAuth}
|
||||||
>
|
>
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
Authenticate Claude in Pool
|
{t('accountsPage.authClaudeInPool')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -96,7 +98,7 @@ export function AccountsPage() {
|
|||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={handleOpenClaudePool}
|
onClick={handleOpenClaudePool}
|
||||||
>
|
>
|
||||||
Open Claude Pool Settings
|
{t('accountsPage.openClaudePoolSettings')}
|
||||||
<ArrowRight className="w-4 h-4 ml-auto" />
|
<ArrowRight className="w-4 h-4 ml-auto" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +106,7 @@ export function AccountsPage() {
|
|||||||
{hasLegacyFollowUp ? (
|
{hasLegacyFollowUp ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Migration Follow-up
|
{t('accountsPage.migrationFollowup')}
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3">
|
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -112,16 +114,14 @@ export function AccountsPage() {
|
|||||||
<div className="space-y-1 text-xs">
|
<div className="space-y-1 text-xs">
|
||||||
{legacyContextCount > 0 && (
|
{legacyContextCount > 0 && (
|
||||||
<p className="text-amber-800 dark:text-amber-300">
|
<p className="text-amber-800 dark:text-amber-300">
|
||||||
{legacyContextCount} account
|
{t('accountsPage.legacyContextPending', { count: legacyContextCount })}
|
||||||
{legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
|
|
||||||
mode confirmation.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{legacyContinuityCount > 0 && (
|
{legacyContinuityCount > 0 && (
|
||||||
<p className="text-amber-800 dark:text-amber-300">
|
<p className="text-amber-800 dark:text-amber-300">
|
||||||
{legacyContinuityCount} shared account
|
{t('accountsPage.legacyContinuityPending', {
|
||||||
{legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy
|
count: legacyContinuityCount,
|
||||||
continuity depth.
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,14 +134,14 @@ export function AccountsPage() {
|
|||||||
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
||||||
>
|
>
|
||||||
{confirmLegacyMutation.isPending
|
{confirmLegacyMutation.isPending
|
||||||
? 'Confirming Legacy Policies...'
|
? t('accountsPage.confirmingLegacy')
|
||||||
: `Confirm Legacy Policies (${legacyTargetCount})`}
|
: t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
|
<div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
|
||||||
No legacy follow-up pending.
|
{t('accountsPage.noLegacyFollowup')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -151,9 +151,11 @@ export function AccountsPage() {
|
|||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
|
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<CardTitle className="text-sm">Continuity Guide</CardTitle>
|
<CardTitle className="text-sm">
|
||||||
|
{t('accountsPage.continuityGuide')}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
Expand only when needed.
|
{t('accountsPage.expandWhenNeeded')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -165,21 +167,26 @@ export function AccountsPage() {
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-3 text-xs text-muted-foreground">
|
<CardContent className="space-y-3 text-xs text-muted-foreground">
|
||||||
<div className="rounded-md border p-2.5">
|
<div className="rounded-md border p-2.5">
|
||||||
<p className="font-semibold text-foreground">Shared Standard</p>
|
<p className="font-semibold text-foreground">
|
||||||
|
{t('accountsPage.sharedStandard')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">{t('accountsPage.sharedStandardDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-2.5">
|
||||||
|
<p className="font-semibold text-foreground">
|
||||||
|
{t('accountsPage.sharedDeeper')}
|
||||||
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
Project workspace sync only. Best default for most teams.
|
{t('accountsPage.sharedDeeperPrefix')} <code>session-env</code>,{' '}
|
||||||
|
<code>file-history</code>, <code>shell-snapshots</code>,{' '}
|
||||||
|
<code>todos</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border p-2.5">
|
<div className="rounded-md border p-2.5">
|
||||||
<p className="font-semibold text-foreground">Shared Deeper</p>
|
<p className="font-semibold text-foreground">
|
||||||
<p className="mt-1">
|
{t('accountsPage.isolated')}
|
||||||
Adds <code>session-env</code>, <code>file-history</code>,{' '}
|
|
||||||
<code>shell-snapshots</code>, <code>todos</code>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="mt-1">{t('accountsPage.isolatedDesc')}</p>
|
||||||
<div className="rounded-md border p-2.5">
|
|
||||||
<p className="font-semibold text-foreground">Isolated</p>
|
|
||||||
<p className="mt-1">No link. Best for strict separation.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -188,8 +195,8 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Quick Commands</CardTitle>
|
<CardTitle className="text-sm">{t('accountsPage.quickCommands')}</CardTitle>
|
||||||
<CardDescription>Copy and run in terminal.</CardDescription>
|
<CardDescription>{t('accountsPage.quickCommandsDesc')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
|
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
|
||||||
@@ -215,16 +222,18 @@ export function AccountsPage() {
|
|||||||
<div className="flex-1 min-w-0 flex flex-col bg-background">
|
<div className="flex-1 min-w-0 flex flex-col bg-background">
|
||||||
<div className="px-5 py-4 border-b bg-background">
|
<div className="px-5 py-4 border-b bg-background">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">ccs auth Workspace</Badge>
|
<Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
|
||||||
<Badge variant="secondary">History Sync Controls</Badge>
|
<Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">Auth Accounts</h2>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">
|
||||||
|
{t('accountsPage.authAccounts')}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
This table is intentionally scoped to
|
{t('accountsPage.tableScopePrefix')}
|
||||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||||
accounts. Use
|
{t('accountsPage.tableScopeMiddle')}
|
||||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
|
||||||
for mode/group/depth changes.
|
{t('accountsPage.tableScopeSuffix')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,15 +249,14 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<Card className="flex flex-col">
|
<Card className="flex flex-col">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-lg">Account Matrix</CardTitle>
|
<CardTitle className="text-lg">{t('accountsPage.accountMatrix')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Shared total: {sharedCount}. Actions include Sync settings and legacy
|
{t('accountsPage.sharedTotalDesc', { count: sharedCount })}
|
||||||
confirmation.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading accounts...</div>
|
<div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
|
||||||
) : (
|
) : (
|
||||||
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
||||||
)}
|
)}
|
||||||
@@ -262,24 +270,24 @@ export function AccountsPage() {
|
|||||||
<div className="p-4 space-y-4 lg:hidden">
|
<div className="p-4 space-y-4 lg:hidden">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Accounts</CardTitle>
|
<CardTitle className="text-lg">{t('accountsPage.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage
|
{t('accountsPage.managePrefix')}
|
||||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||||
continuity per account.
|
{t('accountsPage.mobileManageSuffix')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
|
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Account
|
{t('accountsPage.createAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
|
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
|
||||||
Open CLIProxy Claude Pool
|
{t('accountsPage.openCliProxyClaudePool')}
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
|
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
|
||||||
Authenticate Claude in Pool
|
{t('accountsPage.authClaudeInPool')}
|
||||||
<Zap className="w-4 h-4 ml-2" />
|
<Zap className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -289,8 +297,8 @@ export function AccountsPage() {
|
|||||||
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
||||||
>
|
>
|
||||||
{confirmLegacyMutation.isPending
|
{confirmLegacyMutation.isPending
|
||||||
? 'Confirming Legacy Policies...'
|
? t('accountsPage.confirmingLegacy')
|
||||||
: `Confirm Legacy Policies (${legacyTargetCount})`}
|
: t('accountsPage.confirmLegacy', { count: legacyTargetCount })}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -306,11 +314,11 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Account Matrix</CardTitle>
|
<CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading accounts...</div>
|
<div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
|
||||||
) : (
|
) : (
|
||||||
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { subDays, startOfMonth } from 'date-fns';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface AnalyticsHeaderProps {
|
interface AnalyticsHeaderProps {
|
||||||
dateRange: DateRange | undefined;
|
dateRange: DateRange | undefined;
|
||||||
@@ -29,11 +30,13 @@ export function AnalyticsHeader({
|
|||||||
lastUpdatedText,
|
lastUpdatedText,
|
||||||
viewMode,
|
viewMode,
|
||||||
}: AnalyticsHeaderProps) {
|
}: AnalyticsHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between">
|
<div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Analytics</h1>
|
<h1 className="text-xl font-semibold">{t('analytics.title')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
<p className="text-sm text-muted-foreground">{t('analytics.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -51,14 +54,17 @@ export function AnalyticsHeader({
|
|||||||
presets={[
|
presets={[
|
||||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||||
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
||||||
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
|
{
|
||||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
label: t('analytics.month'),
|
||||||
|
range: { from: startOfMonth(new Date()), to: new Date() },
|
||||||
|
},
|
||||||
|
{ label: t('analytics.allTime'), range: { from: undefined, to: new Date() } },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lastUpdatedText && (
|
{lastUpdatedText && (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Updated {lastUpdatedText}
|
{t('analytics.updated', { value: lastUpdatedText })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+28
-21
@@ -29,8 +29,10 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function ApiPage() {
|
export function ApiPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, refetch } = useProfiles();
|
const { data, isLoading, isError, refetch } = useProfiles();
|
||||||
const deleteMutation = useDeleteProfile();
|
const deleteMutation = useDeleteProfile();
|
||||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||||
@@ -105,7 +107,7 @@ export function ApiPage() {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Server className="w-5 h-5 text-primary" />
|
<Server className="w-5 h-5 text-primary" />
|
||||||
<h1 className="font-semibold">API Profiles</h1>
|
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -114,7 +116,7 @@ export function ApiPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
New
|
{t('apiProfiles.new')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ export function ApiPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search profiles..."
|
placeholder={t('apiProfiles.searchPlaceholder')}
|
||||||
className="pl-8 h-9"
|
className="pl-8 h-9"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
@@ -133,20 +135,22 @@ export function ApiPage() {
|
|||||||
{/* Profile List */}
|
{/* Profile List */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
{t('apiProfiles.loadingProfiles')}
|
||||||
|
</div>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<div className="space-y-3 py-8">
|
<div className="space-y-3 py-8">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
|
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Failed to load profiles</p>
|
<p className="text-sm font-medium">{t('apiProfiles.failedLoadTitle')}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Unable to fetch API profiles. Please try again.
|
{t('apiProfiles.failedLoadDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
Retry
|
{t('apiProfiles.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,9 +160,9 @@ export function ApiPage() {
|
|||||||
<div className="space-y-3 py-8">
|
<div className="space-y-3 py-8">
|
||||||
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">No API profiles yet</p>
|
<p className="text-sm font-medium">{t('apiProfiles.noProfilesYet')}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Create your first profile to connect to custom API endpoints
|
{t('apiProfiles.noProfilesDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -169,12 +173,12 @@ export function ApiPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Create Profile
|
{t('apiProfiles.createProfile')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground py-4">
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
No profiles match "{searchQuery}"
|
{t('apiProfiles.noProfileMatch', { query: searchQuery })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,12 +201,12 @@ export function ApiPage() {
|
|||||||
{profiles.length > 0 && (
|
{profiles.length > 0 && (
|
||||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>
|
<span>{t('apiProfiles.profileCount', { count: profiles.length })}</span>
|
||||||
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-600" />
|
<CheckCircle2 className="w-3 h-3 text-green-600" />
|
||||||
{profiles.filter((p) => p.configured).length} configured
|
{t('apiProfiles.configuredCount', {
|
||||||
|
count: profiles.filter((p) => p.configured).length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,9 +257,9 @@ export function ApiPage() {
|
|||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteConfirm}
|
open={!!deleteConfirm}
|
||||||
title="Delete Profile"
|
title={t('apiProfiles.deleteProfileTitle')}
|
||||||
description={`Are you sure you want to delete "${deleteConfirm}"? This will remove the settings file and cannot be undone.`}
|
description={t('apiProfiles.deleteProfileDesc', { name: deleteConfirm ?? '' })}
|
||||||
confirmText="Delete"
|
confirmText={t('apiProfiles.delete')}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||||
onCancel={() => setDeleteConfirm(null)}
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
@@ -264,9 +268,12 @@ export function ApiPage() {
|
|||||||
{/* Unsaved Changes Confirmation */}
|
{/* Unsaved Changes Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingSwitch}
|
open={!!pendingSwitch}
|
||||||
title="Unsaved Changes"
|
title={t('apiProfiles.unsavedChangesTitle')}
|
||||||
description={`You have unsaved changes in "${selectedProfile}". Discard and switch to "${pendingSwitch}"?`}
|
description={t('apiProfiles.unsavedChangesDesc', {
|
||||||
confirmText="Discard & Switch"
|
current: selectedProfile ?? '',
|
||||||
|
next: pendingSwitch ?? '',
|
||||||
|
})}
|
||||||
|
confirmText={t('apiProfiles.discardSwitch')}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setEditorHasChanges(false);
|
setEditorHasChanges(false);
|
||||||
|
|||||||
+35
-22
@@ -34,6 +34,7 @@ import type { AuthStatus, Variant } from '@/lib/api-client';
|
|||||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||||
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// Sidebar provider item
|
// Sidebar provider item
|
||||||
function ProviderSidebarItem({
|
function ProviderSidebarItem({
|
||||||
@@ -45,6 +46,7 @@ function ProviderSidebarItem({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const accountCount = status.accounts?.length || 0;
|
const accountCount = status.accounts?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,12 +73,14 @@ function ProviderSidebarItem({
|
|||||||
{status.authenticated ? (
|
{status.authenticated ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3 text-green-600" />
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
<span className="text-xs text-green-600">Connected</span>
|
<span className="text-xs text-green-600">{t('cliproxyPage.connected')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">Not connected</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('cliproxyPage.notConnected')}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +105,7 @@ function VariantSidebarItem({
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isDeleting?: boolean;
|
isDeleting?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -119,7 +124,7 @@ function VariantSidebarItem({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm truncate">{variant.name}</span>
|
<span className="font-medium text-sm truncate">{variant.name}</span>
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
variant
|
{t('cliproxyPage.variant')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1 uppercase">
|
<Badge variant="outline" className="text-[9px] h-4 px-1 uppercase">
|
||||||
{variant.target || 'claude'}
|
{variant.target || 'claude'}
|
||||||
@@ -129,12 +134,16 @@ function VariantSidebarItem({
|
|||||||
{parentAuth?.authenticated ? (
|
{parentAuth?.authenticated ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3 text-green-600" />
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span>
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{t('cliproxyPage.viaProvider', { provider: variant.provider })}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">Parent not connected</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('cliproxyPage.parentNotConnected')}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,27 +166,25 @@ function VariantSidebarItem({
|
|||||||
|
|
||||||
// Empty state for right panel
|
// Empty state for right panel
|
||||||
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||||
<div className="text-center max-w-md px-8">
|
<div className="text-center max-w-md px-8">
|
||||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
|
||||||
<Zap className="w-8 h-8 text-muted-foreground" />
|
<Zap className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
|
<h2 className="text-xl font-semibold mb-2">{t('cliproxyPage.emptyTitle')}</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">{t('cliproxyPage.emptyDesc')}</p>
|
||||||
Manage OAuth authentication, account preferences, and model selection for CLIProxy
|
|
||||||
providers. Configure how CCS routes requests to different AI backends.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mb-6">
|
<p className="text-xs text-muted-foreground mb-6">
|
||||||
For live usage stats and real-time monitoring, visit the{' '}
|
{t('cliproxyPage.emptyControlPanelPrefix')}{' '}
|
||||||
<a href="/cliproxy/control-panel" className="text-primary hover:underline">
|
<a href="/cliproxy/control-panel" className="text-primary hover:underline">
|
||||||
Control Panel
|
{t('cliproxyPage.controlPanel')}
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={onSetup} className="gap-2">
|
<Button onClick={onSetup} className="gap-2">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Quick Setup
|
{t('cliproxyPage.quickSetup')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,6 +192,7 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CliproxyPage() {
|
export function CliproxyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||||
const { data: variantsData, isFetching } = useCliproxy();
|
const { data: variantsData, isFetching } = useCliproxy();
|
||||||
@@ -337,7 +345,9 @@ export function CliproxyPage() {
|
|||||||
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
{t('cliproxyPage.accountManagement')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -346,7 +356,7 @@ export function CliproxyPage() {
|
|||||||
onClick={() => setWizardOpen(true)}
|
onClick={() => setWizardOpen(true)}
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Quick Setup
|
{t('cliproxyPage.quickSetup')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,7 +364,7 @@ export function CliproxyPage() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
||||||
Providers
|
{t('cliproxyPage.providers')}
|
||||||
</div>
|
</div>
|
||||||
{authLoading ? (
|
{authLoading ? (
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
@@ -380,7 +390,7 @@ export function CliproxyPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
|
||||||
<GitBranch className="w-3 h-3" />
|
<GitBranch className="w-3 h-3" />
|
||||||
Variants
|
{t('cliproxyPage.variants')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{variants.map((variant) => (
|
{variants.map((variant) => (
|
||||||
@@ -408,12 +418,12 @@ export function CliproxyPage() {
|
|||||||
{/* Footer Stats */}
|
{/* Footer Stats */}
|
||||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>
|
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
|
||||||
{providers.length} provider{providers.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Check className="w-3 h-3 text-green-600" />
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
{providers.filter((p) => p.authenticated).length} connected
|
{t('cliproxyPage.connectedCount', {
|
||||||
|
count: providers.filter((p) => p.authenticated).length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +439,10 @@ export function CliproxyPage() {
|
|||||||
// Variant selected - show ProviderEditor with variant profile name
|
// Variant selected - show ProviderEditor with variant profile name
|
||||||
<ProviderEditor
|
<ProviderEditor
|
||||||
provider={selectedVariantData.name}
|
provider={selectedVariantData.name}
|
||||||
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
|
displayName={t('cliproxyPage.variantDisplay', {
|
||||||
|
name: selectedVariantData.name,
|
||||||
|
provider: selectedVariantData.provider,
|
||||||
|
})}
|
||||||
authStatus={parentAuthForVariant}
|
authStatus={parentAuthForVariant}
|
||||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||||
logoProvider={selectedVariantData.provider}
|
logoProvider={selectedVariantData.provider}
|
||||||
|
|||||||
+38
-26
@@ -24,6 +24,7 @@ import {
|
|||||||
import { useCopilot } from '@/hooks/use-copilot';
|
import { useCopilot } from '@/hooks/use-copilot';
|
||||||
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
|
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// Status section component
|
// Status section component
|
||||||
function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
|
function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
@@ -99,6 +100,7 @@ function LoadingSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CopilotPage() {
|
export function CopilotPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
@@ -122,7 +124,7 @@ export function CopilotPage() {
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Github className="w-5 h-5 text-primary" />
|
<Github className="w-5 h-5 text-primary" />
|
||||||
<h1 className="font-semibold">Copilot</h1>
|
<h1 className="font-semibold">{t('copilotPage.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -134,7 +136,7 @@ export function CopilotPage() {
|
|||||||
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">GitHub Copilot proxy</p>
|
<p className="text-xs text-muted-foreground">{t('copilotPage.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Overview */}
|
{/* Status Overview */}
|
||||||
@@ -148,18 +150,18 @@ export function CopilotPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||||
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
|
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
|
||||||
Unofficial API - Use at Your Own Risk
|
{t('copilotPage.unofficialTitle')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
|
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
|
||||||
<li>Reverse-engineered API - may break anytime</li>
|
<li>{t('copilotPage.unofficialItem1')}</li>
|
||||||
<li>Excessive use may trigger account restrictions</li>
|
<li>{t('copilotPage.unofficialItem2')}</li>
|
||||||
<li>No warranty, no responsibility from CCS</li>
|
<li>{t('copilotPage.unofficialItem3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Setup - Binary first, then enabled status */}
|
{/* Setup - Binary first, then enabled status */}
|
||||||
<StatusSection title="Setup">
|
<StatusSection title={t('copilotPage.setup')}>
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Server}
|
icon={Server}
|
||||||
label="copilot-api"
|
label="copilot-api"
|
||||||
@@ -168,8 +170,8 @@ export function CopilotPage() {
|
|||||||
status?.installed
|
status?.installed
|
||||||
? status.version
|
? status.version
|
||||||
? `v${status.version}`
|
? `v${status.version}`
|
||||||
: 'Installed'
|
: t('copilotPage.installed')
|
||||||
: 'Missing'
|
: t('copilotPage.missing')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!status?.installed && (
|
{!status?.installed && (
|
||||||
@@ -182,12 +184,12 @@ export function CopilotPage() {
|
|||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||||
Installing...
|
{t('copilotPage.installing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="w-3.5 h-3.5 mr-1.5" />
|
<Download className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Install copilot-api
|
{t('copilotPage.installCopilotApi')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -195,21 +197,27 @@ export function CopilotPage() {
|
|||||||
{status?.installed && (
|
{status?.installed && (
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Power}
|
icon={Power}
|
||||||
label="Integration"
|
label={t('copilotPage.integration')}
|
||||||
status={status?.enabled ?? false}
|
status={status?.enabled ?? false}
|
||||||
statusText={status?.enabled ? 'Enabled' : 'Disabled'}
|
statusText={
|
||||||
|
status?.enabled ? t('copilotPage.enabled') : t('copilotPage.disabled')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</StatusSection>
|
</StatusSection>
|
||||||
|
|
||||||
{/* Authentication - only show after binary installed */}
|
{/* Authentication - only show after binary installed */}
|
||||||
{status?.installed && (
|
{status?.installed && (
|
||||||
<StatusSection title="Auth">
|
<StatusSection title={t('copilotPage.auth')}>
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Key}
|
icon={Key}
|
||||||
label="GitHub"
|
label={t('copilotPage.github')}
|
||||||
status={status?.authenticated ?? false}
|
status={status?.authenticated ?? false}
|
||||||
statusText={status?.authenticated ? 'Connected' : 'Not Connected'}
|
statusText={
|
||||||
|
status?.authenticated
|
||||||
|
? t('copilotPage.connected')
|
||||||
|
: t('copilotPage.notConnected')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{!status?.authenticated && (
|
{!status?.authenticated && (
|
||||||
<Button
|
<Button
|
||||||
@@ -218,7 +226,9 @@ export function CopilotPage() {
|
|||||||
onClick={() => startAuth()}
|
onClick={() => startAuth()}
|
||||||
disabled={isAuthenticating}
|
disabled={isAuthenticating}
|
||||||
>
|
>
|
||||||
{isAuthenticating ? 'Authenticating...' : 'Authenticate'}
|
{isAuthenticating
|
||||||
|
? t('copilotPage.authenticating')
|
||||||
|
: t('copilotPage.authenticate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</StatusSection>
|
</StatusSection>
|
||||||
@@ -226,15 +236,17 @@ export function CopilotPage() {
|
|||||||
|
|
||||||
{/* Daemon - only show after authenticated */}
|
{/* Daemon - only show after authenticated */}
|
||||||
{status?.authenticated && (
|
{status?.authenticated && (
|
||||||
<StatusSection title="Daemon">
|
<StatusSection title={t('copilotPage.daemon')}>
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Cpu}
|
icon={Cpu}
|
||||||
label="Status"
|
label={t('copilotPage.status')}
|
||||||
status={status?.daemon_running ?? false}
|
status={status?.daemon_running ?? false}
|
||||||
statusText={status?.daemon_running ? 'Running' : 'Stopped'}
|
statusText={
|
||||||
|
status?.daemon_running ? t('copilotPage.running') : t('copilotPage.stopped')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-1 text-xs text-muted-foreground">
|
<div className="px-3 py-1 text-xs text-muted-foreground">
|
||||||
Port: {status?.port ?? 4141}
|
{t('copilotPage.port')}: {status?.port ?? 4141}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
{status?.daemon_running ? (
|
{status?.daemon_running ? (
|
||||||
@@ -246,7 +258,7 @@ export function CopilotPage() {
|
|||||||
disabled={isStoppingDaemon}
|
disabled={isStoppingDaemon}
|
||||||
>
|
>
|
||||||
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
||||||
{isStoppingDaemon ? 'Stopping...' : 'Stop'}
|
{isStoppingDaemon ? t('copilotPage.stopping') : t('copilotPage.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -257,7 +269,7 @@ export function CopilotPage() {
|
|||||||
disabled={isStartingDaemon}
|
disabled={isStartingDaemon}
|
||||||
>
|
>
|
||||||
<Power className="w-3.5 h-3.5 mr-1.5" />
|
<Power className="w-3.5 h-3.5 mr-1.5" />
|
||||||
{isStartingDaemon ? 'Starting...' : 'Start'}
|
{isStartingDaemon ? t('copilotPage.starting') : t('copilotPage.start')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,16 +282,16 @@ export function CopilotPage() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Proxy</span>
|
<span>{t('copilotPage.proxy')}</span>
|
||||||
{status?.daemon_running ? (
|
{status?.daemon_running ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||||
Active
|
{t('copilotPage.active')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<XCircle className="w-3 h-3 text-muted-foreground" />
|
<XCircle className="w-3 h-3 text-muted-foreground" />
|
||||||
Inactive
|
{t('copilotPage.inactive')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+127
-99
@@ -52,6 +52,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface CursorConfigDraft {
|
interface CursorConfigDraft {
|
||||||
port: string;
|
port: string;
|
||||||
@@ -156,6 +157,7 @@ function CursorModelSelector({
|
|||||||
allowDefaultFallback?: boolean;
|
allowDefaultFallback?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const selectorValue = value || (allowDefaultFallback ? '__default' : '');
|
const selectorValue = value || (allowDefaultFallback ? '__default' : '');
|
||||||
const selected = models.find((model) => model.id === value);
|
const selected = models.find((model) => model.id === value);
|
||||||
|
|
||||||
@@ -177,12 +179,12 @@ function CursorModelSelector({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9">
|
||||||
<SelectValue placeholder="Select model">
|
<SelectValue placeholder={t('cursorPage.selectModel')}>
|
||||||
{selectorValue && (
|
{selectorValue && (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="truncate font-mono text-xs">
|
<span className="truncate font-mono text-xs">
|
||||||
{allowDefaultFallback && selectorValue === '__default'
|
{allowDefaultFallback && selectorValue === '__default'
|
||||||
? 'Use Default Model'
|
? t('cursorPage.useDefaultModel')
|
||||||
: selected?.name || selectorValue}
|
: selected?.name || selectorValue}
|
||||||
</span>
|
</span>
|
||||||
{selected?.provider && (
|
{selected?.provider && (
|
||||||
@@ -197,9 +199,11 @@ function CursorModelSelector({
|
|||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="text-xs text-muted-foreground">
|
<SelectLabel className="text-xs text-muted-foreground">
|
||||||
Available Models ({models.length})
|
{t('cursorPage.availableModelCount', { count: models.length })}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{allowDefaultFallback && <SelectItem value="__default">Use Default Model</SelectItem>}
|
{allowDefaultFallback && (
|
||||||
|
<SelectItem value="__default">{t('cursorPage.useDefaultModel')}</SelectItem>
|
||||||
|
)}
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<SelectItem key={model.id} value={model.id}>
|
<SelectItem key={model.id} value={model.id}>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -252,6 +256,7 @@ function StatusItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CursorPage() {
|
export function CursorPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
@@ -342,8 +347,13 @@ export function CursorPage() {
|
|||||||
|
|
||||||
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
|
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
|
||||||
const integrationBadge = useMemo(
|
const integrationBadge = useMemo(
|
||||||
() => (status?.enabled ? <Badge>Enabled</Badge> : <Badge variant="secondary">Disabled</Badge>),
|
() =>
|
||||||
[status?.enabled]
|
status?.enabled ? (
|
||||||
|
<Badge>{t('cursorPage.enabled')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{t('cursorPage.disabled')}</Badge>
|
||||||
|
),
|
||||||
|
[status?.enabled, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveConfig = async ({
|
const handleSaveConfig = async ({
|
||||||
@@ -351,11 +361,11 @@ export function CursorPage() {
|
|||||||
}: { suppressSuccessToast?: boolean } = {}) => {
|
}: { suppressSuccessToast?: boolean } = {}) => {
|
||||||
const parsedPort = Number(effectivePort);
|
const parsedPort = Number(effectivePort);
|
||||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||||
toast.error('Port must be an integer between 1 and 65535');
|
toast.error(t('cursorPage.invalidPort'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!effectiveModel.trim()) {
|
if (!effectiveModel.trim()) {
|
||||||
toast.error('Default model is required');
|
toast.error(t('cursorPage.defaultModelRequired'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,23 +392,23 @@ export function CursorPage() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!suppressSuccessToast) {
|
if (!suppressSuccessToast) {
|
||||||
toast.success('Cursor config and model mapping saved');
|
toast.success(t('cursorPage.savedConfig'));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Failed to save config');
|
toast.error((error as Error).message || t('cursorPage.failedSaveConfig'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => {
|
const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => {
|
||||||
if (modelsLoading) {
|
if (modelsLoading) {
|
||||||
toast.error('Models are still loading. Please wait before applying a preset.');
|
toast.error(t('cursorPage.modelsLoadingWait'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
toast.error('No models available yet. Start the daemon and refresh first.');
|
toast.error(t('cursorPage.noModelsAvailable'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +467,7 @@ export function CursorPage() {
|
|||||||
sonnet_model: codex53,
|
sonnet_model: codex53,
|
||||||
haiku_model: codexMini,
|
haiku_model: codexMini,
|
||||||
}));
|
}));
|
||||||
toast.success('Applied GPT-5.3 Codex preset');
|
toast.success(t('cursorPage.appliedCodexPreset'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +479,7 @@ export function CursorPage() {
|
|||||||
sonnet_model: sonnet45,
|
sonnet_model: sonnet45,
|
||||||
haiku_model: haiku45,
|
haiku_model: haiku45,
|
||||||
}));
|
}));
|
||||||
toast.success('Applied Claude 4.6 preset');
|
toast.success(t('cursorPage.appliedClaudePreset'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,30 +490,32 @@ export function CursorPage() {
|
|||||||
sonnet_model: gemini3Pro,
|
sonnet_model: gemini3Pro,
|
||||||
haiku_model: gemini3Flash,
|
haiku_model: gemini3Flash,
|
||||||
}));
|
}));
|
||||||
toast.success('Applied Gemini 3 preset');
|
toast.success(t('cursorPage.appliedGeminiPreset'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = async (enabled: boolean) => {
|
const handleToggleEnabled = async (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await updateConfigAsync({ enabled });
|
await updateConfigAsync({ enabled });
|
||||||
toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled');
|
toast.success(
|
||||||
|
enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled')
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Failed to update integration state');
|
toast.error((error as Error).message || t('cursorPage.failedUpdateIntegration'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoDetectAuth = async () => {
|
const handleAutoDetectAuth = async () => {
|
||||||
try {
|
try {
|
||||||
await autoDetectAuthAsync();
|
await autoDetectAuthAsync();
|
||||||
toast.success('Cursor credentials imported');
|
toast.success(t('cursorPage.credentialsImported'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Auto-detect failed');
|
toast.error((error as Error).message || t('cursorPage.autoDetectFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManualAuthImport = async () => {
|
const handleManualAuthImport = async () => {
|
||||||
if (!manualToken.trim() || !manualMachineId.trim()) {
|
if (!manualToken.trim() || !manualMachineId.trim()) {
|
||||||
toast.error('Token and machine ID are required');
|
toast.error(t('cursorPage.manualRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,12 +524,12 @@ export function CursorPage() {
|
|||||||
accessToken: manualToken.trim(),
|
accessToken: manualToken.trim(),
|
||||||
machineId: manualMachineId.trim(),
|
machineId: manualMachineId.trim(),
|
||||||
});
|
});
|
||||||
toast.success('Cursor credentials imported');
|
toast.success(t('cursorPage.credentialsImported'));
|
||||||
setManualAuthOpen(false);
|
setManualAuthOpen(false);
|
||||||
setManualToken('');
|
setManualToken('');
|
||||||
setManualMachineId('');
|
setManualMachineId('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Manual import failed');
|
toast.error((error as Error).message || t('cursorPage.manualImportFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -525,12 +537,16 @@ export function CursorPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await startDaemonAsync();
|
const result = await startDaemonAsync();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error || 'Failed to start daemon');
|
toast.error(result.error || t('cursorPage.failedStartDaemon'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Daemon started${result.pid ? ` (PID: ${result.pid})` : ''}`);
|
toast.success(
|
||||||
|
result.pid
|
||||||
|
? t('cursorPage.daemonStartedWithPid', { pid: result.pid })
|
||||||
|
: t('cursorPage.daemonStarted')
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Failed to start daemon');
|
toast.error((error as Error).message || t('cursorPage.failedStartDaemon'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,12 +554,12 @@ export function CursorPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await stopDaemonAsync();
|
const result = await stopDaemonAsync();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error || 'Failed to stop daemon');
|
toast.error(result.error || t('cursorPage.failedStopDaemon'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Daemon stopped');
|
toast.success(t('cursorPage.daemonStopped'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Failed to stop daemon');
|
toast.error((error as Error).message || t('cursorPage.failedStopDaemon'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,12 +567,12 @@ export function CursorPage() {
|
|||||||
suppressSuccessToast = false,
|
suppressSuccessToast = false,
|
||||||
}: { suppressSuccessToast?: boolean } = {}) => {
|
}: { suppressSuccessToast?: boolean } = {}) => {
|
||||||
if (!rawSettingsReady) {
|
if (!rawSettingsReady) {
|
||||||
toast.error('Raw settings are still loading. Please wait and try again.');
|
toast.error(t('cursorPage.rawLoading'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawParseResult.isValid || !rawParseResult.settings) {
|
if (!rawParseResult.isValid || !rawParseResult.settings) {
|
||||||
toast.error(rawParseResult.error || 'Invalid JSON');
|
toast.error(rawParseResult.error || t('cursorPage.invalidJson'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,14 +583,14 @@ export function CursorPage() {
|
|||||||
});
|
});
|
||||||
setRawConfigDirty(false);
|
setRawConfigDirty(false);
|
||||||
if (!suppressSuccessToast) {
|
if (!suppressSuccessToast) {
|
||||||
toast.success('Raw settings saved');
|
toast.success(t('cursorPage.rawSaved'));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isApiConflictError(error)) {
|
if (isApiConflictError(error)) {
|
||||||
toast.error('Raw settings changed externally. Refresh and retry.');
|
toast.error(t('cursorPage.rawChanged'));
|
||||||
} else {
|
} else {
|
||||||
toast.error((error as Error).message || 'Failed to save raw settings');
|
toast.error((error as Error).message || t('cursorPage.failedSaveRaw'));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -601,7 +617,7 @@ export function CursorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (saveConfig && saveRawSettings) {
|
if (saveConfig && saveRawSettings) {
|
||||||
toast.success('Cursor configuration saved');
|
toast.success(t('cursorPage.savedAll'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -623,12 +639,12 @@ export function CursorPage() {
|
|||||||
alt=""
|
alt=""
|
||||||
className="w-5 h-5 object-contain shrink-0"
|
className="w-5 h-5 object-contain shrink-0"
|
||||||
/>
|
/>
|
||||||
<h1 className="font-semibold">Cursor</h1>
|
<h1 className="font-semibold">{t('cursorPage.title')}</h1>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-5 border-amber-500/60 bg-amber-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300"
|
className="h-5 border-amber-500/60 bg-amber-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300"
|
||||||
>
|
>
|
||||||
Beta
|
{t('cursorPage.beta')}
|
||||||
</Badge>
|
</Badge>
|
||||||
{integrationBadge}
|
{integrationBadge}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,7 +658,7 @@ export function CursorPage() {
|
|||||||
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Dedicated Cursor integration controls</p>
|
<p className="text-xs text-muted-foreground">{t('cursorPage.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
@@ -651,46 +667,48 @@ export function CursorPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||||
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
|
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
|
||||||
Unofficial API - Use at Your Own Risk
|
{t('cursorPage.unofficialTitle')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
|
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
|
||||||
<li>Reverse-engineered integration may break anytime</li>
|
<li>{t('cursorPage.unofficialItem1')}</li>
|
||||||
<li>Abuse or excessive usage may risk account restrictions</li>
|
<li>{t('cursorPage.unofficialItem2')}</li>
|
||||||
<li>No warranty, no responsibility from CCS</li>
|
<li>{t('cursorPage.unofficialItem3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
label="Integration"
|
label={t('cursorPage.integration')}
|
||||||
ok={Boolean(status?.enabled)}
|
ok={Boolean(status?.enabled)}
|
||||||
detail={status?.enabled ? 'Enabled' : 'Disabled'}
|
detail={status?.enabled ? t('cursorPage.enabled') : t('cursorPage.disabled')}
|
||||||
/>
|
/>
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Key}
|
icon={Key}
|
||||||
label="Authentication"
|
label={t('cursorPage.authentication')}
|
||||||
ok={Boolean(status?.authenticated && !status?.token_expired)}
|
ok={Boolean(status?.authenticated && !status?.token_expired)}
|
||||||
detail={
|
detail={
|
||||||
status?.authenticated
|
status?.authenticated
|
||||||
? status?.token_expired
|
? status?.token_expired
|
||||||
? 'Expired'
|
? t('cursorPage.expired')
|
||||||
: (status.auth_method ?? 'Connected')
|
: (status.auth_method ?? t('cursorPage.connected'))
|
||||||
: 'Not connected'
|
: t('cursorPage.notConnected')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusItem
|
<StatusItem
|
||||||
icon={Server}
|
icon={Server}
|
||||||
label="Daemon"
|
label={t('cursorPage.daemon')}
|
||||||
ok={Boolean(status?.daemon_running)}
|
ok={Boolean(status?.daemon_running)}
|
||||||
detail={status?.daemon_running ? 'Running' : 'Stopped'}
|
detail={
|
||||||
|
status?.daemon_running ? t('cursorPage.running') : t('cursorPage.stopped')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Actions
|
{t('cursorPage.actions')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{status?.enabled ? (
|
{status?.enabled ? (
|
||||||
@@ -702,7 +720,7 @@ export function CursorPage() {
|
|||||||
disabled={isUpdatingConfig}
|
disabled={isUpdatingConfig}
|
||||||
>
|
>
|
||||||
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Disable Integration
|
{t('cursorPage.disableIntegration')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -712,7 +730,7 @@ export function CursorPage() {
|
|||||||
disabled={isUpdatingConfig}
|
disabled={isUpdatingConfig}
|
||||||
>
|
>
|
||||||
<Power className="w-3.5 h-3.5 mr-1.5" />
|
<Power className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Enable Integration
|
{t('cursorPage.enableIntegration')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -728,7 +746,7 @@ export function CursorPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Key className="w-3.5 h-3.5 mr-1.5" />
|
<Key className="w-3.5 h-3.5 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
Auto-detect Auth
|
{t('cursorPage.autoDetectAuth')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -738,7 +756,7 @@ export function CursorPage() {
|
|||||||
onClick={() => setManualAuthOpen(true)}
|
onClick={() => setManualAuthOpen(true)}
|
||||||
>
|
>
|
||||||
<Key className="w-3.5 h-3.5 mr-1.5" />
|
<Key className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Manual Auth Import
|
{t('cursorPage.manualAuthImport')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{status?.daemon_running ? (
|
{status?.daemon_running ? (
|
||||||
@@ -754,7 +772,7 @@ export function CursorPage() {
|
|||||||
) : (
|
) : (
|
||||||
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
Stop Daemon
|
{t('cursorPage.stopDaemon')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -768,7 +786,7 @@ export function CursorPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Play className="w-3.5 h-3.5 mr-1.5" />
|
<Play className="w-3.5 h-3.5 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
Start Daemon
|
{t('cursorPage.startDaemon')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -777,7 +795,7 @@ export function CursorPage() {
|
|||||||
|
|
||||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Port</span>
|
<span>{t('cursorPage.port')}</span>
|
||||||
<span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span>
|
<span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -789,7 +807,7 @@ export function CursorPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">Cursor Configuration</h2>
|
<h2 className="text-lg font-semibold">{t('cursorPage.configuration')}</h2>
|
||||||
{rawSettings && (
|
{rawSettings && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
cursor.settings.json
|
cursor.settings.json
|
||||||
@@ -798,10 +816,10 @@ export function CursorPage() {
|
|||||||
</div>
|
</div>
|
||||||
{rawSettings && (
|
{rawSettings && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Last modified:{' '}
|
{t('cursorPage.lastModified')}{' '}
|
||||||
{rawSettings.exists
|
{rawSettings.exists
|
||||||
? new Date(rawSettings.mtime).toLocaleString()
|
? new Date(rawSettings.mtime).toLocaleString()
|
||||||
: 'Never saved'}
|
: t('cursorPage.neverSaved')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -828,12 +846,12 @@ export function CursorPage() {
|
|||||||
{isUpdatingConfig || isSavingRawSettings ? (
|
{isUpdatingConfig || isSavingRawSettings ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
Saving...
|
{t('cursorPage.saving')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="w-4 h-4 mr-1" />
|
<Save className="w-4 h-4 mr-1" />
|
||||||
Save
|
{t('cursorPage.save')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -846,13 +864,13 @@ export function CursorPage() {
|
|||||||
<div className="px-4 pt-4 shrink-0">
|
<div className="px-4 pt-4 shrink-0">
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="config" className="flex-1">
|
<TabsTrigger value="config" className="flex-1">
|
||||||
Model Config
|
{t('cursorPage.modelConfig')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="settings" className="flex-1">
|
<TabsTrigger value="settings" className="flex-1">
|
||||||
Settings
|
{t('cursorPage.settings')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="info" className="flex-1">
|
<TabsTrigger value="info" className="flex-1">
|
||||||
Info
|
{t('cursorPage.info')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@@ -867,10 +885,10 @@ export function CursorPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Presets
|
{t('cursorPage.presets')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
Apply pre-configured model mappings
|
{t('cursorPage.presetsDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -912,14 +930,16 @@ export function CursorPage() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
<h3 className="text-sm font-medium mb-2">
|
||||||
|
{t('cursorPage.modelMapping')}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
Configure which models to use for each tier
|
{t('cursorPage.modelMappingDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CursorModelSelector
|
<CursorModelSelector
|
||||||
label="Default Model"
|
label={t('cursorPage.defaultModel')}
|
||||||
description="Used when no specific tier is requested."
|
description={t('cursorPage.defaultModelDesc')}
|
||||||
value={effectiveModel}
|
value={effectiveModel}
|
||||||
models={orderedModels}
|
models={orderedModels}
|
||||||
disabled={modelsLoading}
|
disabled={modelsLoading}
|
||||||
@@ -928,8 +948,8 @@ export function CursorPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CursorModelSelector
|
<CursorModelSelector
|
||||||
label="Opus (Most capable)"
|
label={t('cursorPage.opusModel')}
|
||||||
description="Complex reasoning and highest quality responses."
|
description={t('cursorPage.opusModelDesc')}
|
||||||
value={effectiveOpusModel}
|
value={effectiveOpusModel}
|
||||||
models={orderedModels}
|
models={orderedModels}
|
||||||
disabled={modelsLoading}
|
disabled={modelsLoading}
|
||||||
@@ -939,8 +959,8 @@ export function CursorPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CursorModelSelector
|
<CursorModelSelector
|
||||||
label="Sonnet (Balanced)"
|
label={t('cursorPage.sonnetModel')}
|
||||||
description="General coding and day-to-day chat workloads."
|
description={t('cursorPage.sonnetModelDesc')}
|
||||||
value={effectiveSonnetModel}
|
value={effectiveSonnetModel}
|
||||||
models={orderedModels}
|
models={orderedModels}
|
||||||
disabled={modelsLoading}
|
disabled={modelsLoading}
|
||||||
@@ -950,8 +970,8 @@ export function CursorPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CursorModelSelector
|
<CursorModelSelector
|
||||||
label="Haiku (Fast)"
|
label={t('cursorPage.haikuModel')}
|
||||||
description="Low-latency and lightweight request paths."
|
description={t('cursorPage.haikuModelDesc')}
|
||||||
value={effectiveHaikuModel}
|
value={effectiveHaikuModel}
|
||||||
models={orderedModels}
|
models={orderedModels}
|
||||||
disabled={modelsLoading}
|
disabled={modelsLoading}
|
||||||
@@ -973,7 +993,9 @@ export function CursorPage() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Runtime Settings</h3>
|
<h3 className="text-sm font-medium">
|
||||||
|
{t('cursorPage.runtimeSettings')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cursor-port" className="text-xs">
|
<Label htmlFor="cursor-port" className="text-xs">
|
||||||
@@ -998,10 +1020,10 @@ export function CursorPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="cursor-auto-start" className="text-xs">
|
<Label htmlFor="cursor-auto-start" className="text-xs">
|
||||||
Auto-start Daemon
|
{t('cursorPage.autoStartDaemon')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Start Cursor daemon automatically when integration is used.
|
{t('cursorPage.autoStartDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1016,10 +1038,10 @@ export function CursorPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="cursor-ghost-mode" className="text-xs">
|
<Label htmlFor="cursor-ghost-mode" className="text-xs">
|
||||||
Ghost Mode
|
{t('cursorPage.ghostMode')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Request `x-ghost-mode` to reduce telemetry.
|
{t('cursorPage.ghostModeDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1043,11 +1065,15 @@ export function CursorPage() {
|
|||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">Provider</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('cursorPage.provider')}
|
||||||
|
</span>
|
||||||
<span className="font-mono">Cursor IDE</span>
|
<span className="font-mono">Cursor IDE</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
<span className="font-medium text-muted-foreground">File Path</span>
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{t('cursorPage.filePath')}
|
||||||
|
</span>
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||||
{rawSettings?.path ?? '~/.ccs/cursor.settings.json'}
|
{rawSettings?.path ?? '~/.ccs/cursor.settings.json'}
|
||||||
</code>
|
</code>
|
||||||
@@ -1060,15 +1086,17 @@ export function CursorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-3">Available Models</h3>
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
{t('cursorPage.availableModels')}
|
||||||
|
</h3>
|
||||||
{modelsLoading ? (
|
{modelsLoading ? (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Loading models...
|
{t('cursorPage.loadingModels')}
|
||||||
</div>
|
</div>
|
||||||
) : models.length === 0 ? (
|
) : models.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No model metadata available yet. Start daemon and refresh.
|
{t('cursorPage.noModels')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1083,7 +1111,9 @@ export function CursorPage() {
|
|||||||
{model.name} • {model.provider}
|
{model.name} • {model.provider}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{model.id === currentModel && <Badge>Default</Badge>}
|
{model.id === currentModel && (
|
||||||
|
<Badge>{t('cursorPage.default')}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1100,7 +1130,7 @@ export function CursorPage() {
|
|||||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
Raw Configuration (JSON)
|
{t('cursorPage.rawConfiguration')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<RawEditorSection
|
<RawEditorSection
|
||||||
@@ -1122,36 +1152,34 @@ export function CursorPage() {
|
|||||||
<Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}>
|
<Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Manual Cursor Auth Import</DialogTitle>
|
<DialogTitle>{t('cursorPage.manualImportTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('cursorPage.manualImportDesc')}</DialogDescription>
|
||||||
Provide Cursor access token and machine ID if auto-detect is unavailable.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cursor-manual-token">Access Token</Label>
|
<Label htmlFor="cursor-manual-token">{t('cursorPage.accessToken')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cursor-manual-token"
|
id="cursor-manual-token"
|
||||||
value={manualToken}
|
value={manualToken}
|
||||||
onChange={(e) => setManualToken(e.target.value)}
|
onChange={(e) => setManualToken(e.target.value)}
|
||||||
placeholder="Paste Cursor access token"
|
placeholder={t('cursorPage.accessTokenPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cursor-manual-machine-id">Machine ID</Label>
|
<Label htmlFor="cursor-manual-machine-id">{t('cursorPage.machineId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cursor-manual-machine-id"
|
id="cursor-manual-machine-id"
|
||||||
value={manualMachineId}
|
value={manualMachineId}
|
||||||
onChange={(e) => setManualMachineId(e.target.value)}
|
onChange={(e) => setManualMachineId(e.target.value)}
|
||||||
placeholder="32-char hex (UUID without hyphens)"
|
placeholder={t('cursorPage.machineIdPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setManualAuthOpen(false)}>
|
<Button variant="outline" onClick={() => setManualAuthOpen(false)}>
|
||||||
Cancel
|
{t('cursorPage.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}>
|
<Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}>
|
||||||
{isImportingManualAuth ? (
|
{isImportingManualAuth ? (
|
||||||
@@ -1159,7 +1187,7 @@ export function CursorPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Key className="w-4 h-4 mr-2" />
|
<Key className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Import
|
{t('cursorPage.import')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
+69
-51
@@ -27,6 +27,7 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
applyAnthropicBudgetTokensToDroidByokModel,
|
applyAnthropicBudgetTokensToDroidByokModel,
|
||||||
applyReasoningEffortToDroidByokModel,
|
applyReasoningEffortToDroidByokModel,
|
||||||
@@ -166,6 +167,7 @@ function DetailRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DroidPage() {
|
export function DroidPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
diagnostics,
|
diagnostics,
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
@@ -201,7 +203,7 @@ export function DroidPage() {
|
|||||||
|
|
||||||
const updateSettingsField = (key: string, value: unknown | null) => {
|
const updateSettingsField = (key: string, value: unknown | null) => {
|
||||||
if (!rawEditorParsed.valid) {
|
if (!rawEditorParsed.valid) {
|
||||||
toast.error('Fix JSON syntax before using quick settings controls.');
|
toast.error(t('droidPage.fixJsonBeforeQuickSettings'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@ export function DroidPage() {
|
|||||||
|
|
||||||
const handleSaveRawSettings = async () => {
|
const handleSaveRawSettings = async () => {
|
||||||
if (!rawEditorValidation.valid) {
|
if (!rawEditorValidation.valid) {
|
||||||
toast.error(`Invalid JSON: ${rawEditorValidation.error}`);
|
toast.error(t('droidPage.invalidJson', { value: rawEditorValidation.error }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +260,12 @@ export function DroidPage() {
|
|||||||
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
|
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
|
||||||
});
|
});
|
||||||
setRawDraftText(null);
|
setRawDraftText(null);
|
||||||
toast.success('Droid settings saved');
|
toast.success(t('droidPage.saved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isApiConflictError(error)) {
|
if (isApiConflictError(error)) {
|
||||||
toast.error('Droid settings changed externally. Refresh and retry.');
|
toast.error(t('droidPage.changedExternally'));
|
||||||
} else {
|
} else {
|
||||||
toast.error((error as Error).message || 'Failed to save Droid settings');
|
toast.error((error as Error).message || t('droidPage.failedSave'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -279,7 +281,7 @@ export function DroidPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
Loading Droid diagnostics...
|
{t('droidPage.loadingDiagnostics')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,7 +289,7 @@ export function DroidPage() {
|
|||||||
if (diagnosticsError || !diagnostics) {
|
if (diagnosticsError || !diagnostics) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-destructive px-6 text-center">
|
<div className="flex h-full items-center justify-center text-destructive px-6 text-center">
|
||||||
Failed to load Droid diagnostics.
|
{t('droidPage.failedDiagnostics')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -314,9 +316,9 @@ export function DroidPage() {
|
|||||||
<Tabs defaultValue="byok" className="h-full flex flex-col">
|
<Tabs defaultValue="byok" className="h-full flex flex-col">
|
||||||
<div className="px-4 pt-4 shrink-0">
|
<div className="px-4 pt-4 shrink-0">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">{t('droidPage.overview')}</TabsTrigger>
|
||||||
<TabsTrigger value="byok">BYOK</TabsTrigger>
|
<TabsTrigger value="byok">{t('droidPage.byok')}</TabsTrigger>
|
||||||
<TabsTrigger value="docs">Docs</TabsTrigger>
|
<TabsTrigger value="docs">{t('droidPage.docs')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -328,34 +330,40 @@ export function DroidPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<TerminalSquare className="h-4 w-4" />
|
<TerminalSquare className="h-4 w-4" />
|
||||||
Runtime & Installation
|
{t('droidPage.runtimeInstall')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Status</span>
|
<span className="text-sm text-muted-foreground">{t('droidPage.status')}</span>
|
||||||
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||||
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
|
{diagnostics.binary.installed
|
||||||
|
? t('droidPage.detected')
|
||||||
|
: t('droidPage.notFound')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Binary path"
|
label={t('droidPage.detectionSource')}
|
||||||
value={diagnostics.binary.path || 'Not detected'}
|
value={diagnostics.binary.source}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Install directory"
|
label={t('droidPage.binaryPath')}
|
||||||
|
value={diagnostics.binary.path || t('droidPage.notFound')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('droidPage.installDirectory')}
|
||||||
value={diagnostics.binary.installDir || 'N/A'}
|
value={diagnostics.binary.installDir || 'N/A'}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Version"
|
label={t('droidPage.version')}
|
||||||
value={diagnostics.binary.version || 'Unknown'}
|
value={diagnostics.binary.version || 'Unknown'}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Override (CCS_DROID_PATH)"
|
label={t('droidPage.overridePath')}
|
||||||
value={diagnostics.binary.overridePath || 'Not set'}
|
value={diagnostics.binary.overridePath || 'Not set'}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
@@ -366,7 +374,7 @@ export function DroidPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Folder className="h-4 w-4" />
|
<Folder className="h-4 w-4" />
|
||||||
Config Files
|
{t('droidPage.configFiles')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -380,15 +388,25 @@ export function DroidPage() {
|
|||||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DetailRow label="Path" value={file.path} mono />
|
<DetailRow label={t('droidPage.path')} value={file.path} mono />
|
||||||
<DetailRow label="Resolved" value={file.resolvedPath} mono />
|
<DetailRow label={t('droidPage.resolved')} value={file.resolvedPath} mono />
|
||||||
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
|
<DetailRow
|
||||||
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
|
label={t('droidPage.size')}
|
||||||
|
value={formatBytes(file.sizeBytes)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label={t('droidPage.lastModified')}
|
||||||
|
value={formatTimestamp(file.mtimeMs)}
|
||||||
|
/>
|
||||||
{file.parseError && (
|
{file.parseError && (
|
||||||
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p>
|
<p className="text-xs text-amber-600">
|
||||||
|
{t('droidPage.parseWarning', { value: file.parseError })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{file.readError && (
|
{file.readError && (
|
||||||
<p className="text-xs text-destructive">Read warning: {file.readError}</p>
|
<p className="text-xs text-destructive">
|
||||||
|
{t('droidPage.readWarning', { value: file.readError })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -400,7 +418,7 @@ export function DroidPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
Warnings
|
{t('droidPage.warnings')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1.5">
|
<CardContent className="space-y-1.5">
|
||||||
@@ -444,11 +462,11 @@ export function DroidPage() {
|
|||||||
disabledReason={
|
disabledReason={
|
||||||
rawEditorParsed.valid
|
rawEditorParsed.valid
|
||||||
? null
|
? null
|
||||||
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}`
|
: `${t('droidPage.byok')}: ${rawEditorParsed.error}`
|
||||||
}
|
}
|
||||||
onEffortChange={(modelId, effort) => {
|
onEffortChange={(modelId, effort) => {
|
||||||
if (!rawEditorParsed.valid) {
|
if (!rawEditorParsed.valid) {
|
||||||
toast.error('Fix JSON syntax before updating BYOK reasoning settings.');
|
toast.error(t('droidPage.fixJsonBeforeReasoning'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +476,7 @@ export function DroidPage() {
|
|||||||
effort
|
effort
|
||||||
);
|
);
|
||||||
if (!nextSettings) {
|
if (!nextSettings) {
|
||||||
toast.error('Unable to update selected BYOK model reasoning setting.');
|
toast.error(t('droidPage.unableUpdateReasoning'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +484,7 @@ export function DroidPage() {
|
|||||||
}}
|
}}
|
||||||
onAnthropicBudgetChange={(modelId, budgetTokens) => {
|
onAnthropicBudgetChange={(modelId, budgetTokens) => {
|
||||||
if (!rawEditorParsed.valid) {
|
if (!rawEditorParsed.valid) {
|
||||||
toast.error('Fix JSON syntax before updating thinking budget.');
|
toast.error(t('droidPage.fixJsonBeforeBudget'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +494,7 @@ export function DroidPage() {
|
|||||||
budgetTokens
|
budgetTokens
|
||||||
);
|
);
|
||||||
if (!nextSettings) {
|
if (!nextSettings) {
|
||||||
toast.error('Thinking budget is only available for Anthropic BYOK models.');
|
toast.error(t('droidPage.anthropicOnlyBudget'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,38 +506,38 @@ export function DroidPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
BYOK Summary
|
{t('droidPage.byokSummary')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Active model selector"
|
label={t('droidPage.activeModelSelector')}
|
||||||
value={diagnostics.byok.activeModelSelector || 'Not set'}
|
value={diagnostics.byok.activeModelSelector || 'Not set'}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Custom models"
|
label={t('droidPage.customModels')}
|
||||||
value={String(diagnostics.byok.customModelCount)}
|
value={String(diagnostics.byok.customModelCount)}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="CCS-managed"
|
label={t('droidPage.ccsManaged')}
|
||||||
value={String(diagnostics.byok.ccsManagedCount)}
|
value={String(diagnostics.byok.ccsManagedCount)}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="User-managed"
|
label={t('droidPage.userManaged')}
|
||||||
value={String(diagnostics.byok.userManagedCount)}
|
value={String(diagnostics.byok.userManagedCount)}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Malformed entries"
|
label={t('droidPage.malformedEntries')}
|
||||||
value={String(diagnostics.byok.invalidModelEntryCount)}
|
value={String(diagnostics.byok.invalidModelEntryCount)}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">Providers</p>
|
<p className="text-xs text-muted-foreground">{t('droidPage.providers')}</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{providerRows.length === 0 && (
|
{providerRows.length === 0 && (
|
||||||
<Badge variant="secondary" className="font-mono">
|
<Badge variant="secondary" className="font-mono">
|
||||||
none
|
{t('droidPage.none')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{providerRows.map(([provider, count]) => (
|
{providerRows.map(([provider, count]) => (
|
||||||
@@ -534,20 +552,20 @@ export function DroidPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">Custom Models</CardTitle>
|
<CardTitle className="text-base">{t('droidPage.customModelsTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="rounded-md border overflow-hidden">
|
||||||
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
|
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
|
||||||
<span>Name / Model</span>
|
<span>{t('droidPage.modelName')}</span>
|
||||||
<span>Provider</span>
|
<span>{t('droidPage.provider')}</span>
|
||||||
<span>Base URL</span>
|
<span>{t('droidPage.baseUrl')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-52">
|
<ScrollArea className="h-52">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{customModels.length === 0 && (
|
{customModels.length === 0 && (
|
||||||
<div className="px-3 py-4 text-xs text-muted-foreground">
|
<div className="px-3 py-4 text-xs text-muted-foreground">
|
||||||
No custom models
|
{t('droidPage.noCustomModels')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{customModels.map((model) => (
|
{customModels.map((model) => (
|
||||||
@@ -593,7 +611,7 @@ export function DroidPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck className="h-4 w-4" />
|
||||||
Docs-Aligned Notes
|
{t('droidPage.docsAlignedNotes')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm">
|
<CardContent className="space-y-2 text-sm">
|
||||||
@@ -605,7 +623,7 @@ export function DroidPage() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
Factory Docs
|
{t('droidPage.factoryDocs')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{docsLinks.map((link) => (
|
{docsLinks.map((link) => (
|
||||||
@@ -633,7 +651,7 @@ export function DroidPage() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
Provider Fact-Check Docs
|
{t('droidPage.providerFactCheckDocs')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{providerDocs.map((providerDoc) => (
|
{providerDocs.map((providerDoc) => (
|
||||||
@@ -660,10 +678,10 @@ export function DroidPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Provider values: {providerValues.join(', ')}
|
{t('droidPage.providerValues', { value: providerValues.join(', ') })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Settings hierarchy: {settingsHierarchy.join(' -> ')}
|
{t('droidPage.settingsHierarchy', { value: settingsHierarchy.join(' -> ') })}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -686,7 +704,7 @@ export function DroidPage() {
|
|||||||
</PanelResizeHandle>
|
</PanelResizeHandle>
|
||||||
<Panel defaultSize={55} minSize={35}>
|
<Panel defaultSize={55} minSize={35}>
|
||||||
<RawJsonSettingsEditorPanel
|
<RawJsonSettingsEditorPanel
|
||||||
title="Droid BYOK Settings"
|
title={t('droidPage.settingsTitle')}
|
||||||
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
|
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
|
||||||
loading={rawSettingsLoading}
|
loading={rawSettingsLoading}
|
||||||
parseWarning={rawSettings?.parseError}
|
parseWarning={rawSettings?.parseError}
|
||||||
|
|||||||
+23
-16
@@ -9,6 +9,7 @@ import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
||||||
if (summary.errors > 0) return 'error';
|
if (summary.errors > 0) return 'error';
|
||||||
@@ -16,14 +17,17 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n
|
|||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(
|
||||||
|
timestamp: number,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string
|
||||||
|
): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
if (seconds < 5) return 'just now';
|
if (seconds < 5) return t('health.justNow');
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
if (seconds < 60) return t('health.secondsAgo', { count: seconds });
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return t('health.minutesAgo', { count: minutes });
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
return `${hours}h ago`;
|
return t('health.hoursAgo', { count: hours });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
|
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
|
||||||
@@ -75,6 +79,7 @@ function LoadingSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HealthPage() {
|
export function HealthPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
||||||
|
|
||||||
// Use dataUpdatedAt directly instead of storing in state
|
// Use dataUpdatedAt directly instead of storing in state
|
||||||
@@ -91,12 +96,12 @@ export function HealthPage() {
|
|||||||
|
|
||||||
const copyDoctorCommand = () => {
|
const copyDoctorCommand = () => {
|
||||||
navigator.clipboard.writeText('ccs doctor');
|
navigator.clipboard.writeText('ccs doctor');
|
||||||
toast.success('Copied to clipboard');
|
toast.success(t('health.copied'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.info('Refreshing health checks...');
|
toast.info(t('health.refreshing'));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
@@ -154,10 +159,12 @@ export function HealthPage() {
|
|||||||
|
|
||||||
{/* Main title */}
|
{/* Main title */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold font-mono tracking-tight">System Health</h1>
|
<h1 className="text-2xl font-bold font-mono tracking-tight">
|
||||||
|
{t('health.systemHealth')}
|
||||||
|
</h1>
|
||||||
{data?.version && (
|
{data?.version && (
|
||||||
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
||||||
build {data.version}
|
{t('health.build', { version: data.version })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -165,12 +172,12 @@ export function HealthPage() {
|
|||||||
{/* Status message */}
|
{/* Status message */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Last scan:</span>
|
<span className="text-muted-foreground">{t('health.lastScan')}</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'}
|
{lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">|</span>
|
<span className="text-muted-foreground">|</span>
|
||||||
<span className="text-muted-foreground">Auto-refresh:</span>
|
<span className="text-muted-foreground">{t('health.autoRefresh')}</span>
|
||||||
<span className="font-mono text-green-500">30s</span>
|
<span className="font-mono text-green-500">30s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +202,7 @@ export function HealthPage() {
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||||
<span className="hidden sm:inline">Refresh</span>
|
<span className="hidden sm:inline">{t('health.refresh')}</span>
|
||||||
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
R
|
R
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -237,10 +244,10 @@ export function HealthPage() {
|
|||||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>
|
<span>
|
||||||
Version <span className="font-mono">{data?.version ?? '--'}</span>
|
{t('health.version')} <span className="font-mono">{data?.version ?? '--'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Platform{' '}
|
{t('health.platform')}{' '}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||||
</span>
|
</span>
|
||||||
@@ -248,7 +255,7 @@ export function HealthPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
<span>Live monitoring active</span>
|
<span>{t('health.liveMonitoring')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useOverview } from '@/hooks/use-overview';
|
|||||||
import { useSharedSummary } from '@/hooks/use-shared';
|
import { useSharedSummary } from '@/hooks/use-shared';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const HEALTH_VARIANTS = {
|
const HEALTH_VARIANTS = {
|
||||||
ok: 'success',
|
ok: 'success',
|
||||||
@@ -62,6 +63,7 @@ function InlineStat({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: overview, isLoading: isOverviewLoading } = useOverview();
|
const { data: overview, isLoading: isOverviewLoading } = useOverview();
|
||||||
const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
|
const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
|
||||||
@@ -134,28 +136,28 @@ export function HomePage() {
|
|||||||
{/* Right: Inline Stats */}
|
{/* Right: Inline Stats */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<InlineStat
|
<InlineStat
|
||||||
title="Profiles"
|
title={t('home.profiles')}
|
||||||
value={overview?.profiles ?? 0}
|
value={overview?.profiles ?? 0}
|
||||||
icon={Key}
|
icon={Key}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
onClick={() => navigate('/providers')}
|
onClick={() => navigate('/providers')}
|
||||||
/>
|
/>
|
||||||
<InlineStat
|
<InlineStat
|
||||||
title="CLIProxy"
|
title={t('home.cliproxy')}
|
||||||
value={overview?.cliproxy ?? 0}
|
value={overview?.cliproxy ?? 0}
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
onClick={() => navigate('/cliproxy')}
|
onClick={() => navigate('/cliproxy')}
|
||||||
/>
|
/>
|
||||||
<InlineStat
|
<InlineStat
|
||||||
title="Accounts"
|
title={t('home.accounts')}
|
||||||
value={overview?.accounts ?? 0}
|
value={overview?.accounts ?? 0}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => navigate('/accounts')}
|
onClick={() => navigate('/accounts')}
|
||||||
/>
|
/>
|
||||||
<InlineStat
|
<InlineStat
|
||||||
title="Health"
|
title={t('home.health')}
|
||||||
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
variant={healthVariant}
|
variant={healthVariant}
|
||||||
@@ -169,7 +171,7 @@ export function HomePage() {
|
|||||||
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Configuration Required</AlertTitle>
|
<AlertTitle>{t('home.configurationRequired')}</AlertTitle>
|
||||||
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
|
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-9
@@ -12,8 +12,10 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { AlertCircle, Loader2, Lock } from 'lucide-react';
|
import { AlertCircle, Loader2, Lock } from 'lucide-react';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,7 +49,7 @@ export function LoginPage() {
|
|||||||
await login(username, password);
|
await login(username, password);
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed');
|
setError(err instanceof Error ? err.message : t('auth.loginFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -60,8 +62,8 @@ export function LoginPage() {
|
|||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
<Lock className="h-6 w-6 text-primary" />
|
<Lock className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">CCS Dashboard</CardTitle>
|
<CardTitle className="text-2xl">{t('auth.dashboardTitle')}</CardTitle>
|
||||||
<CardDescription>Enter your credentials to access the dashboard</CardDescription>
|
<CardDescription>{t('auth.loginDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -72,26 +74,26 @@ export function LoginPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">{t('auth.username')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Enter username"
|
placeholder={t('auth.usernamePlaceholder')}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter password"
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
required
|
required
|
||||||
@@ -101,10 +103,10 @@ export function LoginPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Signing in...
|
{t('auth.signingIn')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Sign In'
|
t('auth.signIn')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,22 +6,24 @@
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react';
|
import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react';
|
||||||
import type { SettingsTab } from '../types';
|
import type { SettingsTab } from '../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface TabNavigationProps {
|
interface TabNavigationProps {
|
||||||
activeTab: SettingsTab;
|
activeTab: SettingsTab;
|
||||||
onTabChange: (tab: SettingsTab) => void;
|
onTabChange: (tab: SettingsTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ value: 'websearch' as const, label: 'Web', icon: Globe },
|
|
||||||
{ value: 'globalenv' as const, label: 'Env', icon: Settings2 },
|
|
||||||
{ value: 'thinking' as const, label: 'Think', icon: Brain },
|
|
||||||
{ value: 'proxy' as const, label: 'Proxy', icon: Server },
|
|
||||||
{ value: 'auth' as const, label: 'Auth', icon: KeyRound },
|
|
||||||
{ value: 'backups' as const, label: 'Backup', icon: Archive },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tabs = [
|
||||||
|
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
||||||
|
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
|
||||||
|
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
|
||||||
|
{ value: 'proxy' as const, label: t('settingsTabs.proxy'), icon: Server },
|
||||||
|
{ value: 'auth' as const, label: t('settingsTabs.auth'), icon: KeyRound },
|
||||||
|
{ value: 'backups' as const, label: t('settingsTabs.backup'), icon: Archive },
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||||
<TabsList className="grid w-full grid-cols-6">
|
<TabsList className="grid w-full grid-cols-6">
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { useSettingsTab, useRawConfig } from './hooks';
|
|||||||
import { TabNavigation } from './components/tab-navigation';
|
import { TabNavigation } from './components/tab-navigation';
|
||||||
import { SectionSkeleton } from './components/section-skeleton';
|
import { SectionSkeleton } from './components/section-skeleton';
|
||||||
import type { SettingsTab } from './types';
|
import type { SettingsTab } from './types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry wrapper for dynamic imports with exponential backoff
|
* Retry wrapper for dynamic imports with exponential backoff
|
||||||
@@ -72,15 +74,19 @@ class SectionErrorBoundary extends Component<
|
|||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center p-6 max-w-md">
|
<div className="text-center p-6 max-w-md">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
||||||
<p className="font-medium text-foreground mb-2">Failed to load section</p>
|
<p className="font-medium text-foreground mb-2">
|
||||||
<p className="text-sm mb-4">{this.state.error?.message || 'Unknown error occurred'}</p>
|
{i18n.t('settings.sectionLoadFailed')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mb-4">
|
||||||
|
{this.state.error?.message || i18n.t('settings.unknownError')}
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="inline-flex items-center gap-2"
|
className="inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
Reload page
|
{i18n.t('settings.reloadPage')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +99,7 @@ class SectionErrorBoundary extends Component<
|
|||||||
|
|
||||||
// Inner component that uses context
|
// Inner component that uses context
|
||||||
function SettingsPageInner() {
|
function SettingsPageInner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { activeTab, setActiveTab } = useSettingsTab();
|
const { activeTab, setActiveTab } = useSettingsTab();
|
||||||
const {
|
const {
|
||||||
rawConfig,
|
rawConfig,
|
||||||
@@ -178,12 +185,12 @@ function SettingsPageInner() {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 mr-1" />
|
<Check className="w-4 h-4 mr-1" />
|
||||||
Copied
|
{t('settings.copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
Copy
|
{t('settings.copy')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -203,7 +210,7 @@ function SettingsPageInner() {
|
|||||||
{rawConfigLoading ? (
|
{rawConfigLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||||
Loading...
|
{t('settings.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : rawConfig ? (
|
) : rawConfig ? (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
@@ -218,7 +225,7 @@ function SettingsPageInner() {
|
|||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
<p>Config file not found</p>
|
<p>{t('settings.configFileNotFound')}</p>
|
||||||
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
|
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
|
||||||
ccs migrate
|
ccs migrate
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRawConfig } from '../hooks';
|
import { useRawConfig } from '../hooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface TokenInfo {
|
interface TokenInfo {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -35,6 +36,7 @@ interface AuthTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthSection() {
|
export default function AuthSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { fetchRawConfig } = useRawConfig();
|
const { fetchRawConfig } = useRawConfig();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -60,16 +62,16 @@ export default function AuthSection() {
|
|||||||
// Use /raw to get unmasked values for editing
|
// Use /raw to get unmasked values for editing
|
||||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch auth tokens');
|
throw new Error(t('settingsAuth.failedFetchTokens'));
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTokens(data);
|
setTokens(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
// Load on mount
|
// Load on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,16 +119,16 @@ export default function AuthSection() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to save tokens');
|
throw new Error(data.error || t('settingsAuth.failedSaveTokens'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('Tokens updated. Restart CLIProxy to apply.');
|
setSuccess(t('settingsAuth.tokensUpdated'));
|
||||||
setEditedApiKey(null);
|
setEditedApiKey(null);
|
||||||
setEditedSecret(null);
|
setEditedSecret(null);
|
||||||
await fetchTokens();
|
await fetchTokens();
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -143,14 +145,14 @@ export default function AuthSection() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to regenerate secret');
|
throw new Error(data.error || t('settingsAuth.failedRegenerate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('New management secret generated. Restart CLIProxy to apply.');
|
setSuccess(t('settingsAuth.secretRegenerated'));
|
||||||
await fetchTokens();
|
await fetchTokens();
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -167,16 +169,16 @@ export default function AuthSection() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to reset tokens');
|
throw new Error(data.error || t('settingsAuth.failedReset'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.');
|
setSuccess(t('settingsAuth.tokensReset'));
|
||||||
setEditedApiKey(null);
|
setEditedApiKey(null);
|
||||||
setEditedSecret(null);
|
setEditedSecret(null);
|
||||||
await fetchTokens();
|
await fetchTokens();
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ export default function AuthSection() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>{t('settings.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -251,31 +253,27 @@ export default function AuthSection() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-5 space-y-6">
|
<div className="p-5 space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('settingsAuth.description')}</p>
|
||||||
Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* API Key Section */}
|
{/* API Key Section */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KeyRound className="w-4 h-4 text-primary" />
|
<KeyRound className="w-4 h-4 text-primary" />
|
||||||
<h3 className="text-base font-medium">API Key</h3>
|
<h3 className="text-base font-medium">{t('settingsAuth.apiKey')}</h3>
|
||||||
{tokens.apiKey.isCustom && (
|
{tokens.apiKey.isCustom && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||||
Custom
|
{t('settingsAuth.custom')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsAuth.apiKeyDesc')}</p>
|
||||||
Used by Claude Code to authenticate with CLIProxy
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
type={showApiKey ? 'text' : 'password'}
|
type={showApiKey ? 'text' : 'password'}
|
||||||
value={displayApiKey}
|
value={displayApiKey}
|
||||||
onChange={(e) => setEditedApiKey(e.target.value)}
|
onChange={(e) => setEditedApiKey(e.target.value)}
|
||||||
placeholder="API key"
|
placeholder={t('settingsAuth.apiKeyPlaceholder')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="pr-20 font-mono text-sm"
|
className="pr-20 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -310,15 +308,15 @@ export default function AuthSection() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||||
<h3 className="text-base font-medium">Management Secret</h3>
|
<h3 className="text-base font-medium">{t('settingsAuth.managementSecret')}</h3>
|
||||||
{tokens.managementSecret.isCustom && (
|
{tokens.managementSecret.isCustom && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||||
Custom
|
{t('settingsAuth.custom')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Used by CCS dashboard to access CLIProxy management APIs
|
{t('settingsAuth.managementSecretDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -326,7 +324,7 @@ export default function AuthSection() {
|
|||||||
type={showSecret ? 'text' : 'password'}
|
type={showSecret ? 'text' : 'password'}
|
||||||
value={displaySecret}
|
value={displaySecret}
|
||||||
onChange={(e) => setEditedSecret(e.target.value)}
|
onChange={(e) => setEditedSecret(e.target.value)}
|
||||||
placeholder="Management secret"
|
placeholder={t('settingsAuth.managementSecretPlaceholder')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="pr-20 font-mono text-sm"
|
className="pr-20 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -359,7 +357,7 @@ export default function AuthSection() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={regenerateSecret}
|
onClick={regenerateSecret}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
title="Generate new secure secret"
|
title={t('settingsAuth.generateSecret')}
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -375,11 +373,9 @@ export default function AuthSection() {
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Reset to Defaults
|
{t('settingsAuth.resetDefaults')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">{t('settingsAuth.resetDesc')}</p>
|
||||||
Resets both API key and management secret to their default values.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -394,7 +390,7 @@ export default function AuthSection() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -404,7 +400,7 @@ export default function AuthSection() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
|
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? t('settingsAuth.saving') : t('settingsAuth.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react';
|
import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react';
|
||||||
import { useRawConfig } from '../hooks';
|
import { useRawConfig } from '../hooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** Duration in ms before success toast auto-dismisses */
|
/** Duration in ms before success toast auto-dismisses */
|
||||||
const SUCCESS_DISPLAY_DURATION_MS = 3000;
|
const SUCCESS_DISPLAY_DURATION_MS = 3000;
|
||||||
@@ -39,6 +40,7 @@ interface BackupsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BackupsSection() {
|
export default function BackupsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { fetchRawConfig } = useRawConfig();
|
const { fetchRawConfig } = useRawConfig();
|
||||||
|
|
||||||
// AbortController refs for cleanup
|
// AbortController refs for cleanup
|
||||||
@@ -66,18 +68,18 @@ export default function BackupsSection() {
|
|||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch backups');
|
throw new Error(t('settingsBackups.failedFetch'));
|
||||||
}
|
}
|
||||||
const data: BackupsResponse = await response.json();
|
const data: BackupsResponse = await response.json();
|
||||||
setBackups(data.backups || []);
|
setBackups(data.backups || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore abort errors
|
// Ignore abort errors
|
||||||
if (err instanceof Error && err.name === 'AbortError') return;
|
if (err instanceof Error && err.name === 'AbortError') return;
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
// Restore backup (wrapped in useCallback for callback stability)
|
// Restore backup (wrapped in useCallback for callback stability)
|
||||||
const restoreBackup = useCallback(
|
const restoreBackup = useCallback(
|
||||||
@@ -98,21 +100,21 @@ export default function BackupsSection() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to restore backup');
|
throw new Error(data.error || t('settingsBackups.failedRestore'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('Backup restored successfully');
|
setSuccess(t('settingsBackups.restoreSuccess'));
|
||||||
await fetchBackups();
|
await fetchBackups();
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore abort errors
|
// Ignore abort errors
|
||||||
if (err instanceof Error && err.name === 'AbortError') return;
|
if (err instanceof Error && err.name === 'AbortError') return;
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : t('settings.unknownError'));
|
||||||
} finally {
|
} finally {
|
||||||
setRestoring(null);
|
setRestoring(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchBackups, fetchRawConfig]
|
[fetchBackups, fetchRawConfig, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load on mount
|
// Load on mount
|
||||||
@@ -205,12 +207,9 @@ export default function BackupsSection() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Archive className="w-5 h-5 text-primary" />
|
<Archive className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">Settings Backups</h2>
|
<h2 className="text-lg font-semibold">{t('settingsBackups.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('settingsBackups.description')}</p>
|
||||||
Restore previous versions of your settings.json file. Backups are created
|
|
||||||
automatically when settings are modified.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Backups List */}
|
{/* Backups List */}
|
||||||
@@ -218,9 +217,9 @@ export default function BackupsSection() {
|
|||||||
<Card className="p-8">
|
<Card className="p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Archive className="w-12 h-12 mx-auto mb-3 opacity-30 text-muted-foreground" />
|
<Archive className="w-12 h-12 mx-auto mb-3 opacity-30 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No backups available</p>
|
<p className="text-sm text-muted-foreground">{t('settingsBackups.none')}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Backups will appear here when you modify settings
|
{t('settingsBackups.noneHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -236,7 +235,7 @@ export default function BackupsSection() {
|
|||||||
<p className="text-sm font-medium font-mono">{backup.timestamp}</p>
|
<p className="text-sm font-medium font-mono">{backup.timestamp}</p>
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Latest
|
{t('settingsBackups.latest')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +252,9 @@ export default function BackupsSection() {
|
|||||||
<RotateCcw
|
<RotateCcw
|
||||||
className={`w-4 h-4 ${restoring === backup.timestamp ? 'animate-spin' : ''}`}
|
className={`w-4 h-4 ${restoring === backup.timestamp ? 'animate-spin' : ''}`}
|
||||||
/>
|
/>
|
||||||
{restoring === backup.timestamp ? 'Restoring...' : 'Restore'}
|
{restoring === backup.timestamp
|
||||||
|
? t('settingsBackups.restoring')
|
||||||
|
: t('settingsBackups.restore')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -276,7 +277,7 @@ export default function BackupsSection() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,17 +285,17 @@ export default function BackupsSection() {
|
|||||||
<AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}>
|
<AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Restore Backup?</AlertDialogTitle>
|
<AlertDialogTitle>{t('settingsBackups.restoreConfirmTitle')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will replace your current settings with backup from{' '}
|
{t('settingsBackups.restoreConfirmPrefix')}{' '}
|
||||||
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
|
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
{confirmRestore}
|
{confirmRestore}
|
||||||
</code>
|
</code>
|
||||||
. This action cannot be undone.
|
. {t('settingsBackups.restoreConfirmSuffix')}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t('settingsBackups.cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirmRestore) {
|
if (confirmRestore) {
|
||||||
@@ -303,7 +304,7 @@ export default function BackupsSection() {
|
|||||||
setConfirmRestore(null);
|
setConfirmRestore(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Restore
|
{t('settingsBackups.restore')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
|
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useGlobalEnvConfig, useRawConfig } from '../hooks';
|
import { useGlobalEnvConfig, useRawConfig } from '../hooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function GlobalEnvSection() {
|
export default function GlobalEnvSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
@@ -46,7 +48,7 @@ export default function GlobalEnvSection() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>{t('settings.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -71,7 +73,7 @@ export default function GlobalEnvSection() {
|
|||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="text-sm font-medium">Saved</span>
|
<span className="text-sm font-medium">{t('settings.saved')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -79,21 +81,18 @@ export default function GlobalEnvSection() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-5 space-y-6">
|
<div className="p-5 space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('settingsGlobalEnv.description')}</p>
|
||||||
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
|
|
||||||
agy, ghcp, etc.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Enable/Disable Toggle */}
|
{/* Enable/Disable Toggle */}
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'}
|
{config?.enabled ? t('settingsGlobalEnv.enabled') : t('settingsGlobalEnv.disabled')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{config?.enabled
|
{config?.enabled
|
||||||
? 'Env vars will be injected into third-party profiles'
|
? t('settingsGlobalEnv.enabledDesc')
|
||||||
: 'Env vars will not be injected'}
|
: t('settingsGlobalEnv.disabledDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
|
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
|
||||||
@@ -101,7 +100,7 @@ export default function GlobalEnvSection() {
|
|||||||
|
|
||||||
{/* Current Environment Variables */}
|
{/* Current Environment Variables */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Environment Variables</h3>
|
<h3 className="text-base font-medium">{t('settingsGlobalEnv.envVars')}</h3>
|
||||||
|
|
||||||
{config?.env && Object.keys(config.env).length > 0 ? (
|
{config?.env && Object.keys(config.env).length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -127,18 +126,18 @@ export default function GlobalEnvSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
|
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
|
||||||
<p>No environment variables configured</p>
|
<p>{t('settingsGlobalEnv.noneConfigured')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add New Variable */}
|
{/* Add New Variable */}
|
||||||
<div className="p-4 rounded-lg border bg-muted/30">
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
<h4 className="text-sm font-medium mb-3">Add New Variable</h4>
|
<h4 className="text-sm font-medium mb-3">{t('settingsGlobalEnv.addNew')}</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newEnvKey}
|
value={newEnvKey}
|
||||||
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
||||||
placeholder="KEY_NAME"
|
placeholder={t('settingsGlobalEnv.keyName')}
|
||||||
className="flex-1 font-mono text-sm h-9"
|
className="flex-1 font-mono text-sm h-9"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
@@ -146,7 +145,7 @@ export default function GlobalEnvSection() {
|
|||||||
<Input
|
<Input
|
||||||
value={newEnvValue}
|
value={newEnvValue}
|
||||||
onChange={(e) => setNewEnvValue(e.target.value)}
|
onChange={(e) => setNewEnvValue(e.target.value)}
|
||||||
placeholder="value"
|
placeholder={t('settingsGlobalEnv.value')}
|
||||||
className="flex-1 font-mono text-sm h-9"
|
className="flex-1 font-mono text-sm h-9"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
@@ -157,14 +156,14 @@ export default function GlobalEnvSection() {
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Add
|
{t('settingsGlobalEnv.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Common Variables Quick Add */}
|
{/* Common Variables Quick Add */}
|
||||||
<div className="p-4 rounded-lg border bg-muted/30">
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
<h4 className="text-sm font-medium mb-3">Quick Add Common Variables</h4>
|
<h4 className="text-sm font-medium mb-3">{t('settingsGlobalEnv.quickAdd')}</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
|
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
|
||||||
@@ -192,7 +191,7 @@ export default function GlobalEnvSection() {
|
|||||||
(k) => config.env[k]
|
(k) => config.env[k]
|
||||||
) && (
|
) && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
All common variables are configured
|
{t('settingsGlobalEnv.allConfigured')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +213,7 @@ export default function GlobalEnvSection() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { api } from '@/lib/api-client';
|
|||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** LocalStorage key for debug mode preference */
|
/** LocalStorage key for debug mode preference */
|
||||||
const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
||||||
@@ -42,6 +43,7 @@ function normalizeRiskAckPhrase(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProxySection() {
|
export default function ProxySection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
@@ -98,17 +100,17 @@ export default function ProxySection() {
|
|||||||
setAgyAckBypassLoading(true);
|
setAgyAckBypassLoading(true);
|
||||||
const response = await fetch('/api/settings/auth/antigravity-risk');
|
const response = await fetch('/api/settings/auth/antigravity-risk');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load AGY power user mode');
|
throw new Error(t('settingsProxy.failedLoadAgyMode'));
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { antigravityAckBypass?: boolean };
|
const data = (await response.json()) as { antigravityAckBypass?: boolean };
|
||||||
setAgyAckBypass(data.antigravityAckBypass === true);
|
setAgyAckBypass(data.antigravityAckBypass === true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode');
|
toast.error(err instanceof Error ? err.message : t('settingsProxy.failedLoadAgyMode'));
|
||||||
setAgyAckBypass(false);
|
setAgyAckBypass(false);
|
||||||
} finally {
|
} finally {
|
||||||
setAgyAckBypassLoading(false);
|
setAgyAckBypassLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const persistAgyAckBypass = useCallback(
|
const persistAgyAckBypass = useCallback(
|
||||||
async (nextValue: boolean) => {
|
async (nextValue: boolean) => {
|
||||||
@@ -130,7 +132,7 @@ export default function ProxySection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || 'Failed to update AGY power user mode');
|
throw new Error(payload.error || t('settingsProxy.failedUpdateAgyMode'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistedValue = payload.antigravityAckBypass === true;
|
const persistedValue = payload.antigravityAckBypass === true;
|
||||||
@@ -139,29 +141,29 @@ export default function ProxySection() {
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!verifyResponse.ok) {
|
if (!verifyResponse.ok) {
|
||||||
throw new Error('Failed to verify AGY power user mode persistence');
|
throw new Error(t('settingsProxy.failedVerifyAgyMode'));
|
||||||
}
|
}
|
||||||
const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean };
|
const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean };
|
||||||
const verifiedValue = verifyData.antigravityAckBypass === true;
|
const verifiedValue = verifyData.antigravityAckBypass === true;
|
||||||
if (verifiedValue !== nextValue) {
|
if (verifiedValue !== nextValue) {
|
||||||
throw new Error(
|
throw new Error(t('settingsProxy.notPersistedAgyMode'));
|
||||||
'AGY power user mode was not persisted. Config may have been modified by another process.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAgyAckBypass(verifiedValue && persistedValue);
|
setAgyAckBypass(verifiedValue && persistedValue);
|
||||||
setShowAgyEnableConfirm(false);
|
setShowAgyEnableConfirm(false);
|
||||||
setAgyEnableConfirmPhrase('');
|
setAgyEnableConfirmPhrase('');
|
||||||
toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.');
|
toast.success(
|
||||||
|
nextValue ? t('settingsProxy.agyModeEnabled') : t('settingsProxy.agyModeDisabled')
|
||||||
|
);
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode');
|
toast.error(err instanceof Error ? err.message : t('settingsProxy.failedUpdateAgyMode'));
|
||||||
} finally {
|
} finally {
|
||||||
agyAckBypassSavingRef.current = false;
|
agyAckBypassSavingRef.current = false;
|
||||||
setAgyAckBypassSaving(false);
|
setAgyAckBypassSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agyAckBypassSaving, fetchRawConfig, saving]
|
[agyAckBypassSaving, fetchRawConfig, saving, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgyAckBypassChange = useCallback(
|
const handleAgyAckBypassChange = useCallback(
|
||||||
@@ -182,11 +184,11 @@ export default function ProxySection() {
|
|||||||
|
|
||||||
const confirmAgyEnable = useCallback(() => {
|
const confirmAgyEnable = useCallback(() => {
|
||||||
if (!isAgyConfirmPhraseValid) {
|
if (!isAgyConfirmPhraseValid) {
|
||||||
toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`);
|
toast.error(t('settingsProxy.typePhraseToContinue', { value: RISK_ACK_PHRASE }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void persistAgyAckBypass(true);
|
void persistAgyAckBypass(true);
|
||||||
}, [isAgyConfirmPhraseValid, persistAgyAckBypass]);
|
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
|
||||||
|
|
||||||
// Backend state (loaded from API) + mutation hook for proper query invalidation
|
// Backend state (loaded from API) + mutation hook for proper query invalidation
|
||||||
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
||||||
@@ -262,7 +264,7 @@ export default function ProxySection() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>{t('settings.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -308,7 +310,7 @@ export default function ProxySection() {
|
|||||||
|
|
||||||
const parsedPort = Number(portStr);
|
const parsedPort = Number(portStr);
|
||||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||||
toast.error('Port must be an integer between 1 and 65535, or empty for default');
|
toast.error(t('settingsProxy.invalidPortOrEmpty'));
|
||||||
setEditedPort(null);
|
setEditedPort(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -340,7 +342,7 @@ export default function ProxySection() {
|
|||||||
const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr);
|
const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr);
|
||||||
|
|
||||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||||
toast.error('Local port must be an integer between 1 and 65535');
|
toast.error(t('settingsProxy.invalidLocalPort'));
|
||||||
setEditedLocalPort(null);
|
setEditedLocalPort(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -379,7 +381,7 @@ export default function ProxySection() {
|
|||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="text-sm font-medium">Saved</span>
|
<span className="text-sm font-medium">{t('settings.saved')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -388,21 +390,23 @@ export default function ProxySection() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-5 space-y-6">
|
<div className="p-5 space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configure local or remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} connection
|
{t('settingsProxy.description', {
|
||||||
for proxy-based profiles
|
backend:
|
||||||
|
backend === 'plus' ? t('settingsProxy.backendPlus') : t('settingsProxy.backend'),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Proxy Status Widget - Quick access to start/stop controls */}
|
{/* Proxy Status Widget - Quick access to start/stop controls */}
|
||||||
{!isRemoteMode && (
|
{!isRemoteMode && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Instance Status</h3>
|
<h3 className="text-base font-medium">{t('settingsProxy.instanceStatus')}</h3>
|
||||||
<ProxyStatusWidget />
|
<ProxyStatusWidget />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mode Toggle - Card based selection */}
|
{/* Mode Toggle - Card based selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Connection Mode</h3>
|
<h3 className="text-base font-medium">{t('settingsProxy.connectionMode')}</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Local Mode Card */}
|
{/* Local Mode Card */}
|
||||||
<button
|
<button
|
||||||
@@ -418,10 +422,15 @@ export default function ProxySection() {
|
|||||||
<Laptop
|
<Laptop
|
||||||
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Local</span>
|
<span className="font-medium">{t('settingsProxy.local')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Run {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} binary on this machine
|
{t('settingsProxy.localDesc', {
|
||||||
|
backend:
|
||||||
|
backend === 'plus'
|
||||||
|
? t('settingsProxy.backendPlus')
|
||||||
|
: t('settingsProxy.backend'),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -439,10 +448,15 @@ export default function ProxySection() {
|
|||||||
<Cloud
|
<Cloud
|
||||||
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Remote</span>
|
<span className="font-medium">{t('settingsProxy.remote')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Connect to a remote {backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'} server
|
{t('settingsProxy.remoteDesc', {
|
||||||
|
backend:
|
||||||
|
backend === 'plus'
|
||||||
|
? t('settingsProxy.backendPlus')
|
||||||
|
: t('settingsProxy.backend'),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,14 +466,14 @@ export default function ProxySection() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium flex items-center gap-2">
|
<h3 className="text-base font-medium flex items-center gap-2">
|
||||||
<Box className="w-4 h-4" />
|
<Box className="w-4 h-4" />
|
||||||
Backend Binary
|
{t('settingsProxy.backendBinary')}
|
||||||
</h3>
|
</h3>
|
||||||
{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */}
|
{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */}
|
||||||
{!isRemoteMode && isProxyRunning && (
|
{!isRemoteMode && isProxyRunning && (
|
||||||
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
|
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
<AlertDescription className="text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-amber-700 dark:text-amber-400">
|
||||||
Stop the running proxy in Instance Status to switch backend.
|
{t('settingsProxy.stopProxyToSwitch')}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -475,14 +489,12 @@ export default function ProxySection() {
|
|||||||
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
|
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className="font-medium">CLIProxyAPIPlus</span>
|
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||||
Default
|
{t('settingsProxy.default')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
|
||||||
Full provider support including Kiro and GitHub Copilot
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Original Backend Card */}
|
{/* Original Backend Card */}
|
||||||
@@ -496,21 +508,16 @@ export default function ProxySection() {
|
|||||||
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
|
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className="font-medium">CLIProxyAPI</span>
|
<span className="font-medium">{t('settingsProxy.backendApi')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
|
||||||
Original binary (Gemini, Codex, Antigravity only)
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Warning when original backend selected with Kiro/ghcp variants */}
|
{/* Warning when original backend selected with Kiro/ghcp variants */}
|
||||||
{backend === 'original' && hasKiroGhcpVariants && (
|
{backend === 'original' && hasKiroGhcpVariants && (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
|
||||||
Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to
|
|
||||||
CLIProxyAPIPlus or remove those variants.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -519,15 +526,13 @@ export default function ProxySection() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium flex items-center gap-2">
|
<h3 className="text-base font-medium flex items-center gap-2">
|
||||||
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
|
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
|
||||||
Safety
|
{t('settingsProxy.safety')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25">
|
<div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium text-sm">Antigravity Power User Mode</p>
|
<p className="font-medium text-sm">{t('settingsProxy.agyModeTitle')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.agyModeDesc')}</p>
|
||||||
Skip AGY responsibility checklist in Add Account and `ccs agy` flows.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
aria-labelledby="agy-power-user-mode-label"
|
aria-labelledby="agy-power-user-mode-label"
|
||||||
@@ -541,24 +546,22 @@ export default function ProxySection() {
|
|||||||
id="agy-power-user-mode-description"
|
id="agy-power-user-mode-description"
|
||||||
className="text-xs text-amber-800/90 dark:text-amber-200/90"
|
className="text-xs text-amber-800/90 dark:text-amber-200/90"
|
||||||
>
|
>
|
||||||
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
|
{t('settingsProxy.agyWarning')}
|
||||||
cannot assume responsibility for account loss.
|
|
||||||
</p>
|
</p>
|
||||||
{showAgyEnableConfirm && (
|
{showAgyEnableConfirm && (
|
||||||
<div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5">
|
<div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
|
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
|
||||||
Final confirmation required
|
{t('settingsProxy.finalConfirm')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90">
|
<p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90">
|
||||||
Enabling this will skip AGY safety checkpoints in both dashboard and CLI.
|
{t('settingsProxy.finalConfirmDesc')}
|
||||||
Review issue #509 and type the exact phrase to proceed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
|
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
|
||||||
Step 1
|
{t('settingsProxy.step1')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/kaitranntt/ccs/issues/509"
|
href="https://github.com/kaitranntt/ccs/issues/509"
|
||||||
@@ -566,20 +569,20 @@ export default function ProxySection() {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200"
|
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200"
|
||||||
>
|
>
|
||||||
Read issue #509
|
{t('settingsProxy.readIssue')}
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
|
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
|
||||||
Step 2
|
{t('settingsProxy.step2')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90">
|
<p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90">
|
||||||
Type{' '}
|
{t('settingsProxy.typePrefix')}{' '}
|
||||||
<code className="rounded bg-background/80 px-1 py-0.5 font-mono">
|
<code className="rounded bg-background/80 px-1 py-0.5 font-mono">
|
||||||
{RISK_ACK_PHRASE}
|
{RISK_ACK_PHRASE}
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
to enable.
|
{t('settingsProxy.typeSuffix')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -590,10 +593,10 @@ export default function ProxySection() {
|
|||||||
placeholder={RISK_ACK_PHRASE}
|
placeholder={RISK_ACK_PHRASE}
|
||||||
disabled={agyAckBypassSaving || saving}
|
disabled={agyAckBypassSaving || saving}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
aria-label="Type I ACCEPT RISK to enable Antigravity power user mode"
|
aria-label={t('settingsProxy.typePhraseAria')}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-rose-800/90 dark:text-rose-200/80">
|
<p className="text-[11px] text-rose-800/90 dark:text-rose-200/80">
|
||||||
Exact phrase required.
|
{t('settingsProxy.exactPhrase')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -606,7 +609,7 @@ export default function ProxySection() {
|
|||||||
}}
|
}}
|
||||||
disabled={agyAckBypassSaving || saving}
|
disabled={agyAckBypassSaving || saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('settingsBackups.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -614,13 +617,13 @@ export default function ProxySection() {
|
|||||||
onClick={confirmAgyEnable}
|
onClick={confirmAgyEnable}
|
||||||
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
|
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
|
||||||
>
|
>
|
||||||
Enable Power User Mode
|
{t('settingsProxy.enableAgyMode')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span id="agy-power-user-mode-label" className="sr-only">
|
<span id="agy-power-user-mode-label" className="sr-only">
|
||||||
Toggle AGY power user mode
|
{t('settingsProxy.toggleAgyMode')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,14 +654,14 @@ export default function ProxySection() {
|
|||||||
|
|
||||||
{/* Fallback Settings */}
|
{/* Fallback Settings */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Fallback Settings</h3>
|
<h3 className="text-base font-medium">{t('settingsProxy.fallbackSettings')}</h3>
|
||||||
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
{/* Enable Fallback */}
|
{/* Enable Fallback */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Enable fallback to local</p>
|
<p className="font-medium text-sm">{t('settingsProxy.enableFallback')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use local proxy if remote is unreachable
|
{t('settingsProxy.enableFallbackDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -673,9 +676,9 @@ export default function ProxySection() {
|
|||||||
{/* Auto-start on fallback */}
|
{/* Auto-start on fallback */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Auto-start local proxy</p>
|
<p className="font-medium text-sm">{t('settingsProxy.autoStartLocal')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Automatically start local proxy on fallback
|
{t('settingsProxy.autoStartLocalDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -693,15 +696,15 @@ export default function ProxySection() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium flex items-center gap-2">
|
<h3 className="text-base font-medium flex items-center gap-2">
|
||||||
<Bug className="w-4 h-4" />
|
<Bug className="w-4 h-4" />
|
||||||
Advanced
|
{t('settingsProxy.advanced')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
{/* Debug Mode Toggle */}
|
{/* Debug Mode Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Debug Mode</p>
|
<p className="font-medium text-sm">{t('settingsProxy.debugMode')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Enable developer diagnostics in browser console
|
{t('settingsProxy.debugModeDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -712,7 +715,7 @@ export default function ProxySection() {
|
|||||||
</div>
|
</div>
|
||||||
{debugMode && (
|
{debugMode && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400 pl-0.5">
|
<p className="text-xs text-amber-600 dark:text-amber-400 pl-0.5">
|
||||||
Debug mode enabled. Check browser console for detailed logs.
|
{t('settingsProxy.debugModeEnabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -748,7 +751,7 @@ export default function ProxySection() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
import type { CliproxyServerConfig } from '../../types';
|
import type { CliproxyServerConfig } from '../../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface LocalProxyCardProps {
|
interface LocalProxyCardProps {
|
||||||
config: CliproxyServerConfig;
|
config: CliproxyServerConfig;
|
||||||
@@ -25,15 +26,16 @@ export function LocalProxyCard({
|
|||||||
onSaveLocalPort,
|
onSaveLocalPort,
|
||||||
onSaveConfig,
|
onSaveConfig,
|
||||||
}: LocalProxyCardProps) {
|
}: LocalProxyCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const localConfig = config.local;
|
const localConfig = config.local;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Local Proxy</h3>
|
<h3 className="text-base font-medium">{t('settingsProxy.localProxy')}</h3>
|
||||||
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
{/* Port */}
|
{/* Port */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground">Port</label>
|
<label className="text-sm text-muted-foreground">{t('settingsProxy.port')}</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -49,10 +51,8 @@ export function LocalProxyCard({
|
|||||||
{/* Auto-start */}
|
{/* Auto-start */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Auto-start</p>
|
<p className="font-medium text-sm">{t('settingsProxy.autoStart')}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.localAutoStartDesc')}</p>
|
||||||
Start local proxy automatically when needed
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={localConfig.auto_start ?? true}
|
checked={localConfig.auto_start ?? true}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
|
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
|
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface RemoteProxyCardProps {
|
interface RemoteProxyCardProps {
|
||||||
config: CliproxyServerConfig;
|
config: CliproxyServerConfig;
|
||||||
@@ -57,6 +58,7 @@ export function RemoteProxyCard({
|
|||||||
onSaveConfig,
|
onSaveConfig,
|
||||||
onTestConnection,
|
onTestConnection,
|
||||||
}: RemoteProxyCardProps) {
|
}: RemoteProxyCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const remoteConfig = config.remote;
|
const remoteConfig = config.remote;
|
||||||
|
|
||||||
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
|
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
|
||||||
@@ -67,17 +69,17 @@ export function RemoteProxyCard({
|
|||||||
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">
|
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
<Cloud className="w-4 h-4" />
|
<Cloud className="w-4 h-4" />
|
||||||
Remote Server Configuration
|
{t('settingsProxy.remoteConfigTitle')}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Host */}
|
{/* Host */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm text-muted-foreground">Host</label>
|
<label className="text-sm text-muted-foreground">{t('settingsProxy.host')}</label>
|
||||||
<Input
|
<Input
|
||||||
value={displayHost}
|
value={displayHost}
|
||||||
onChange={(e) => setEditedHost(e.target.value)}
|
onChange={(e) => setEditedHost(e.target.value)}
|
||||||
onBlur={onSaveHost}
|
onBlur={onSaveHost}
|
||||||
placeholder="192.168.1.100 or proxy.example.com"
|
placeholder={t('settingsProxy.hostPlaceholder')}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
@@ -87,9 +89,13 @@ export function RemoteProxyCard({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm text-muted-foreground">
|
<label className="text-sm text-muted-foreground">
|
||||||
Port{' '}
|
{t('settingsProxy.port')}{' '}
|
||||||
<span className="text-xs opacity-70">
|
<span className="text-xs opacity-70">
|
||||||
(default: {getDefaultPort(config.remote.protocol || 'http')})
|
(
|
||||||
|
{t('settingsProxy.defaultPort', {
|
||||||
|
value: getDefaultPort(config.remote.protocol || 'http'),
|
||||||
|
})}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -98,13 +104,15 @@ export function RemoteProxyCard({
|
|||||||
value={displayPort}
|
value={displayPort}
|
||||||
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
|
||||||
onBlur={onSavePort}
|
onBlur={onSavePort}
|
||||||
placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`}
|
placeholder={t('settingsProxy.portPlaceholder', {
|
||||||
|
value: getDefaultPort(config.remote.protocol || 'http'),
|
||||||
|
})}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm text-muted-foreground">Protocol</label>
|
<label className="text-sm text-muted-foreground">{t('settingsProxy.protocol')}</label>
|
||||||
<Select
|
<Select
|
||||||
value={config.remote.protocol || 'http'}
|
value={config.remote.protocol || 'http'}
|
||||||
onValueChange={(value: 'http' | 'https') =>
|
onValueChange={(value: 'http' | 'https') =>
|
||||||
@@ -116,8 +124,8 @@ export function RemoteProxyCard({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">HTTP</SelectItem>
|
<SelectItem value="http">{t('settingsProxy.http')}</SelectItem>
|
||||||
<SelectItem value="https">HTTPS</SelectItem>
|
<SelectItem value="https">{t('settingsProxy.https')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,36 +133,34 @@ export function RemoteProxyCard({
|
|||||||
|
|
||||||
{/* Auth Token (API Key) */}
|
{/* Auth Token (API Key) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm text-muted-foreground">API Key (optional)</label>
|
<label className="text-sm text-muted-foreground">{t('settingsProxy.apiKeyOptional')}</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={displayAuthToken}
|
value={displayAuthToken}
|
||||||
onChange={(e) => setEditedAuthToken(e.target.value)}
|
onChange={(e) => setEditedAuthToken(e.target.value)}
|
||||||
onBlur={onSaveAuthToken}
|
onBlur={onSaveAuthToken}
|
||||||
placeholder="For /v1/* API endpoints"
|
placeholder={t('settingsProxy.apiKeyPlaceholder')}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.apiKeyDesc')}</p>
|
||||||
Used for API requests to /v1/chat/completions
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Management Key */}
|
{/* Management Key */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm text-muted-foreground">Management Key (optional)</label>
|
<label className="text-sm text-muted-foreground">
|
||||||
|
{t('settingsProxy.managementKeyOptional')}
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={displayManagementKey}
|
value={displayManagementKey}
|
||||||
onChange={(e) => setEditedManagementKey(e.target.value)}
|
onChange={(e) => setEditedManagementKey(e.target.value)}
|
||||||
onBlur={onSaveManagementKey}
|
onBlur={onSaveManagementKey}
|
||||||
placeholder="For /v0/management/* endpoints"
|
placeholder={t('settingsProxy.managementKeyPlaceholder')}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('settingsProxy.managementKeyDesc')}</p>
|
||||||
Used for dashboard management APIs. Falls back to API Key if not set.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test Connection */}
|
{/* Test Connection */}
|
||||||
@@ -168,12 +174,12 @@ export function RemoteProxyCard({
|
|||||||
{testing ? (
|
{testing ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Testing...
|
{t('settingsProxy.testing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Wifi className="w-4 h-4 mr-2" />
|
<Wifi className="w-4 h-4 mr-2" />
|
||||||
Test Connection
|
{t('settingsProxy.testConnection')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -192,14 +198,14 @@ export function RemoteProxyCard({
|
|||||||
<>
|
<>
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||||
Connected ({testResult.latencyMs}ms)
|
{t('settingsProxy.connectedLatency', { value: testResult.latencyMs })}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
|
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
{testResult.error || 'Connection failed'}
|
{testResult.error || t('settingsProxy.connectionFailed')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react';
|
import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react';
|
||||||
import { useThinkingConfig } from '../../hooks';
|
import { useThinkingConfig } from '../../hooks';
|
||||||
import type { ThinkingMode } from '../../types';
|
import type { ThinkingMode } from '../../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const THINKING_LEVELS = [
|
const THINKING_LEVELS = [
|
||||||
{ value: 'minimal', label: 'Minimal (512 tokens)' },
|
{ value: 'minimal', label: 'Minimal (512 tokens)' },
|
||||||
@@ -42,6 +43,7 @@ const THINKING_BUDGET_MIN = 0;
|
|||||||
const THINKING_BUDGET_MAX = 100000;
|
const THINKING_BUDGET_MAX = 100000;
|
||||||
|
|
||||||
export default function ThinkingSection() {
|
export default function ThinkingSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
@@ -125,7 +127,7 @@ export default function ThinkingSection() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>{t('settings.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -156,7 +158,7 @@ export default function ThinkingSection() {
|
|||||||
className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10"
|
className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
Retry
|
{t('sharedPage.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -164,7 +166,7 @@ export default function ThinkingSection() {
|
|||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="text-sm font-medium">Saved</span>
|
<span className="text-sm font-medium">{t('settings.saved')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,27 +176,28 @@ export default function ThinkingSection() {
|
|||||||
<div className="p-5 space-y-6">
|
<div className="p-5 space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Brain className="w-5 h-5 text-primary" />
|
<Brain className="w-5 h-5 text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('settingsThinking.description')}</p>
|
||||||
Configure extended thinking/reasoning for supported models.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* U4: Provider support indicator */}
|
{/* U4: Provider support indicator */}
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
<p className="font-medium">Supported Providers</p>
|
<p className="font-medium">{t('settingsThinking.supportedProviders')}</p>
|
||||||
<ul className="mt-1 space-y-0.5 text-blue-600 dark:text-blue-400">
|
<ul className="mt-1 space-y-0.5 text-blue-600 dark:text-blue-400">
|
||||||
<li>
|
<li>
|
||||||
Thinking budget: <strong>agy</strong>, <strong>gemini</strong> (token-based)
|
{t('settingsThinking.supportLine1Prefix')} <strong>agy</strong>,{' '}
|
||||||
|
<strong>gemini</strong> {t('settingsThinking.supportLine1Suffix')}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Reasoning effort: <strong>codex</strong> (suffix or <code>--effort</code>:
|
{t('settingsThinking.supportLine2Prefix')} <strong>codex</strong>{' '}
|
||||||
medium/high/xhigh)
|
{t('settingsThinking.supportLine2SuffixPrefix')}
|
||||||
|
<code>--effort</code>
|
||||||
|
{t('settingsThinking.supportLine2SuffixPostfix')}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Codex suffixes pin effort (for example <code>-high</code>); unsuffixed models use
|
{t('settingsThinking.supportLine3Prefix')} <code>-high</code>
|
||||||
Thinking mode.
|
{t('settingsThinking.supportLine3Suffix')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +205,7 @@ export default function ThinkingSection() {
|
|||||||
|
|
||||||
{/* Mode Selection */}
|
{/* Mode Selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Thinking Mode</h3>
|
<h3 className="text-base font-medium">{t('settingsThinking.modeTitle')}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (
|
{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (
|
||||||
<div
|
<div
|
||||||
@@ -221,10 +224,9 @@ export default function ThinkingSection() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium capitalize">{mode}</p>
|
<p className="font-medium capitalize">{mode}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{mode === 'auto' && 'Automatically set thinking based on model tier'}
|
{mode === 'auto' && t('settingsThinking.modeAutoDesc')}
|
||||||
{mode === 'off' && 'Disable extended thinking'}
|
{mode === 'off' && t('settingsThinking.modeOffDesc')}
|
||||||
{mode === 'manual' &&
|
{mode === 'manual' && t('settingsThinking.modeManualDesc')}
|
||||||
'Set a persistent override level or use CLI flags per run'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -242,9 +244,9 @@ export default function ThinkingSection() {
|
|||||||
{/* Tier Defaults (only shown when mode is auto) */}
|
{/* Tier Defaults (only shown when mode is auto) */}
|
||||||
{config.mode === 'auto' && (
|
{config.mode === 'auto' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Tier Defaults</h3>
|
<h3 className="text-base font-medium">{t('settingsThinking.tierDefaults')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Default thinking level for each model tier when in auto mode.
|
{t('settingsThinking.tierDefaultsDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -276,9 +278,9 @@ export default function ThinkingSection() {
|
|||||||
{/* Manual Override (shown when mode is manual) */}
|
{/* Manual Override (shown when mode is manual) */}
|
||||||
{config.mode === 'manual' && (
|
{config.mode === 'manual' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Persistent Override</h3>
|
<h3 className="text-base font-medium">{t('settingsThinking.persistentOverride')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Applied to all sessions. CLI flags still take priority.
|
{t('settingsThinking.persistentOverrideDesc')}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={overrideSelectValue}
|
value={overrideSelectValue}
|
||||||
@@ -320,7 +322,7 @@ export default function ThinkingSection() {
|
|||||||
onChange={(event) => setCustomOverrideBudgetInput(event.target.value)}
|
onChange={(event) => setCustomOverrideBudgetInput(event.target.value)}
|
||||||
onBlur={handleApplyCustomBudget}
|
onBlur={handleApplyCustomBudget}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
placeholder="Enter custom budget"
|
placeholder={t('settingsThinking.enterCustomBudget')}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -329,7 +331,7 @@ export default function ThinkingSection() {
|
|||||||
onClick={handleApplyCustomBudget}
|
onClick={handleApplyCustomBudget}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Apply
|
{t('settingsThinking.apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -353,19 +355,21 @@ export default function ThinkingSection() {
|
|||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
|
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
|
||||||
/>
|
/>
|
||||||
Provider Overrides ({Object.keys(config.provider_overrides ?? {}).length})
|
{t('settingsThinking.providerOverrides', {
|
||||||
|
count: Object.keys(config.provider_overrides ?? {}).length,
|
||||||
|
})}
|
||||||
</button>
|
</button>
|
||||||
{providerOverridesOpen && (
|
{providerOverridesOpen && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Override tier defaults for specific providers. Add custom provider keys as needed.
|
{t('settingsThinking.providerOverridesDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={customProviderInput}
|
value={customProviderInput}
|
||||||
onChange={(event) => setCustomProviderInput(event.target.value)}
|
onChange={(event) => setCustomProviderInput(event.target.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
placeholder="Add provider key (e.g. qwen)"
|
placeholder={t('settingsThinking.addProviderPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -374,7 +378,7 @@ export default function ThinkingSection() {
|
|||||||
onClick={handleAddProvider}
|
onClick={handleAddProvider}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Add
|
{t('settingsGlobalEnv.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{providerKeys.map((provider) => (
|
{providerKeys.map((provider) => (
|
||||||
@@ -404,7 +408,9 @@ export default function ThinkingSection() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__default__">Default</SelectItem>
|
<SelectItem value="__default__">
|
||||||
|
{t('cursorPage.default')}
|
||||||
|
</SelectItem>
|
||||||
{THINKING_LEVELS.map((level) => (
|
{THINKING_LEVELS.map((level) => (
|
||||||
<SelectItem key={level.value} value={level.value}>
|
<SelectItem key={level.value} value={level.value}>
|
||||||
{level.label}
|
{level.label}
|
||||||
@@ -425,9 +431,9 @@ export default function ThinkingSection() {
|
|||||||
{/* Show Warnings Toggle */}
|
{/* Show Warnings Toggle */}
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Show Warnings</p>
|
<p className="font-medium">{t('settingsThinking.showWarnings')}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Display warnings when thinking values are clamped or adjusted
|
{t('settingsThinking.showWarningsDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -439,7 +445,7 @@ export default function ThinkingSection() {
|
|||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="p-4 rounded-lg border bg-muted/30">
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
<h4 className="text-sm font-medium mb-2">CLI & Env Override</h4>
|
<h4 className="text-sm font-medium mb-2">{t('settingsThinking.cliEnvOverride')}</h4>
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
Override per session with flags or{' '}
|
Override per session with flags or{' '}
|
||||||
<code className="px-1.5 py-0.5 rounded bg-muted">CCS_THINKING</code> env var.
|
<code className="px-1.5 py-0.5 rounded bg-muted">CCS_THINKING</code> env var.
|
||||||
@@ -465,7 +471,7 @@ export default function ThinkingSection() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
|
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
|
||||||
import { useWebSearchConfig, useRawConfig } from '../../hooks';
|
import { useWebSearchConfig, useRawConfig } from '../../hooks';
|
||||||
import { ProviderCard } from './provider-card';
|
import { ProviderCard } from './provider-card';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function WebSearchSection() {
|
export default function WebSearchSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
status,
|
status,
|
||||||
@@ -83,7 +85,7 @@ export default function WebSearchSection() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<span>Loading...</span>
|
<span>{t('settings.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -108,7 +110,7 @@ export default function WebSearchSection() {
|
|||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="text-sm font-medium">Saved</span>
|
<span className="text-sm font-medium">{t('settings.saved')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,18 +118,16 @@ export default function WebSearchSection() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-5 space-y-6">
|
<div className="p-5 space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.description')}</p>
|
||||||
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Status Summary */}
|
{/* Status Summary */}
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
|
{isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}
|
||||||
</p>
|
</p>
|
||||||
{statusLoading ? (
|
{statusLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Checking status...</p>
|
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.checking')}</p>
|
||||||
) : status?.readiness ? (
|
) : status?.readiness ? (
|
||||||
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
|
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -139,19 +139,21 @@ export default function WebSearchSection() {
|
|||||||
|
|
||||||
{/* CLI Providers */}
|
{/* CLI Providers */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Providers</h3>
|
<h3 className="text-base font-medium">{t('settingsWebsearch.providers')}</h3>
|
||||||
|
|
||||||
{/* Empty state when no providers available */}
|
{/* Empty state when no providers available */}
|
||||||
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
|
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
|
||||||
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
|
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
|
||||||
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
|
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
|
||||||
<p className="font-medium text-foreground mb-1">No providers configured</p>
|
<p className="font-medium text-foreground mb-1">
|
||||||
|
{t('settingsWebsearch.noneConfigured')}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Install CLI tools to enable web search providers
|
{t('settingsWebsearch.noneConfiguredHint')}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={fetchStatus}>
|
<Button variant="outline" size="sm" onClick={fetchStatus}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Check for providers
|
{t('settingsWebsearch.checkProviders')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -233,7 +235,7 @@ export default function WebSearchSection() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
|
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface ProviderCardProps {
|
export interface ProviderCardProps {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -50,6 +51,7 @@ export function ProviderCard({
|
|||||||
docsUrl,
|
docsUrl,
|
||||||
hintColor,
|
hintColor,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const badgeClass =
|
const badgeClass =
|
||||||
badgeColor === 'green'
|
badgeColor === 'green'
|
||||||
? 'bg-green-500/10 text-green-600'
|
? 'bg-green-500/10 text-green-600'
|
||||||
@@ -98,11 +100,11 @@ export function ProviderCard({
|
|||||||
</span>
|
</span>
|
||||||
{installed ? (
|
{installed ? (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||||
installed
|
{t('settingsWebsearch.installed')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||||
not installed
|
{t('settingsWebsearch.notInstalled')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +118,9 @@ export function ProviderCard({
|
|||||||
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
|
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
|
||||||
<div className="px-4 pb-4 pt-0">
|
<div className="px-4 pb-4 pt-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground whitespace-nowrap">Model:</label>
|
<label className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{t('settingsWebsearch.model')}
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={modelInput}
|
value={modelInput}
|
||||||
onChange={(e) => setModelInput(e.target.value)}
|
onChange={(e) => setModelInput(e.target.value)}
|
||||||
@@ -128,7 +132,7 @@ export function ProviderCard({
|
|||||||
{modelSaved && (
|
{modelSaved && (
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
Saved
|
{t('settings.saved')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +147,16 @@ export function ProviderCard({
|
|||||||
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
|
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
|
||||||
>
|
>
|
||||||
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
How to install {displayName} CLI
|
{t('settingsWebsearch.howInstall', { name: displayName })}
|
||||||
</button>
|
</button>
|
||||||
{showHint && (
|
{showHint && (
|
||||||
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
|
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Install globally{' '}
|
{t('settingsWebsearch.installGlobally')}{' '}
|
||||||
{badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}:
|
{badge === 'GROK_API_KEY'
|
||||||
|
? t('settingsWebsearch.requiresKey')
|
||||||
|
: t('settingsWebsearch.freeTier')}
|
||||||
|
:
|
||||||
</p>
|
</p>
|
||||||
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
|
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
|
||||||
{installCmd}
|
{installCmd}
|
||||||
@@ -161,7 +168,7 @@ export function ProviderCard({
|
|||||||
className="hover:underline inline-flex items-center gap-1"
|
className="hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
View documentation
|
{t('settingsWebsearch.viewDocs')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+52
-34
@@ -8,6 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import '@/lib/i18n';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -21,14 +23,15 @@ import {
|
|||||||
|
|
||||||
type TabType = 'commands' | 'skills' | 'agents';
|
type TabType = 'commands' | 'skills' | 'agents';
|
||||||
|
|
||||||
const tabLabels: Record<TabType, string> = {
|
|
||||||
commands: 'Commands',
|
|
||||||
skills: 'Skills',
|
|
||||||
agents: 'Agents',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SharedPage() {
|
export function SharedPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<TabType>('commands');
|
const [tab, setTab] = useState<TabType>('commands');
|
||||||
|
const tabLabels: Record<TabType, string> = {
|
||||||
|
commands: t('sharedPage.commands'),
|
||||||
|
skills: t('sharedPage.skills'),
|
||||||
|
agents: t('sharedPage.agents'),
|
||||||
|
};
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null);
|
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -105,10 +108,8 @@ export function SharedPage() {
|
|||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Shared Data</h1>
|
<h1 className="text-2xl font-bold">{t('sharedPage.title')}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t('sharedPage.subtitle')}</p>
|
||||||
Commands, skills, and agents shared across Claude instances
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -133,14 +134,18 @@ export function SharedPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 lg:items-end">
|
<div className="flex flex-col gap-2 lg:items-end">
|
||||||
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
|
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
|
||||||
<HeaderMetricCard label="Total Shared" value={totalSharedItems} />
|
<HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
|
||||||
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
|
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
|
||||||
<HeaderMetricCard label="Visible" value={filteredItems.length} />
|
<HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<Badge variant="secondary">Markdown detail view</Badge>
|
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
|
||||||
{activeQuery ? <Badge variant="outline">Filter: {activeQuery}</Badge> : null}
|
{activeQuery ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t('sharedPage.filterPrefix')} {activeQuery}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +153,7 @@ export function SharedPage() {
|
|||||||
{summary && !summary.symlinkStatus.valid && (
|
{summary && !summary.symlinkStatus.valid && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Configuration Required</AlertTitle>
|
<AlertTitle>{t('sharedPage.configurationRequired')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
|
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -158,7 +163,7 @@ export function SharedPage() {
|
|||||||
{isSummaryError && (
|
{isSummaryError && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Counts unavailable</AlertTitle>
|
<AlertTitle>{t('sharedPage.countsUnavailable')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>{summaryErrorMessage}</p>
|
<p>{summaryErrorMessage}</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -170,7 +175,7 @@ export function SharedPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry counts
|
{t('sharedPage.retryCounts')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -200,30 +205,38 @@ export function SharedPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={`Filter ${tab} by name, description, or path`}
|
placeholder={t('sharedPage.filterPlaceholder', { tab })}
|
||||||
aria-label={`Filter ${tab} by name, description, or path`}
|
aria-label={t('sharedPage.filterPlaceholder', { tab })}
|
||||||
className="pl-8 h-9"
|
className="pl-8 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && !isError && (
|
{!isLoading && !isError && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Showing {filteredItems.length} of {allItems.length} {tab}
|
{t('sharedPage.showing', {
|
||||||
{activeQuery ? ` for "${activeQuery}"` : ''}
|
visible: filteredItems.length,
|
||||||
{isFetching ? ' (refreshing...)' : ''}
|
total: allItems.length,
|
||||||
|
tab,
|
||||||
|
})}
|
||||||
|
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
||||||
|
{isFetching ? t('sharedPage.refreshing') : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading shared {tab}...</div>
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
{t('sharedPage.loadingShared', { tab })}
|
||||||
|
</div>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<div className="space-y-3 py-8">
|
<div className="space-y-3 py-8">
|
||||||
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Failed to load shared {tab}</p>
|
<p className="text-sm font-medium">
|
||||||
|
{t('sharedPage.failedLoadShared', { tab })}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -234,17 +247,17 @@ export function SharedPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry
|
{t('sharedPage.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : hasNoItems ? (
|
) : hasNoItems ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
No shared {tab} found. Run `ccs sync` or add items in your shared directory.
|
{t('sharedPage.noSharedFound', { tab })}
|
||||||
</div>
|
</div>
|
||||||
) : hasNoMatches ? (
|
) : hasNoMatches ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
No {tab} match "{activeQuery}".
|
{t('sharedPage.noMatch', { tab, query: activeQuery })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
@@ -277,7 +290,7 @@ export function SharedPage() {
|
|||||||
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
|
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
|
||||||
{!selectedItem ? (
|
{!selectedItem ? (
|
||||||
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
|
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
|
||||||
Select a {tab.slice(0, -1)} to view full content.
|
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -293,11 +306,15 @@ export function SharedPage() {
|
|||||||
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
|
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
|
||||||
<div className="rounded-md border bg-muted/35 p-3">
|
<div className="rounded-md border bg-muted/35 p-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<MetadataField label="Path" value={selectedItem.path} mono />
|
<MetadataField
|
||||||
|
label={t('sharedPage.pathLabel')}
|
||||||
|
value={selectedItem.path}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
{selectedItemContent?.contentPath &&
|
{selectedItemContent?.contentPath &&
|
||||||
selectedItemContent.contentPath !== selectedItem.path && (
|
selectedItemContent.contentPath !== selectedItem.path && (
|
||||||
<MetadataField
|
<MetadataField
|
||||||
label="Resolved Source"
|
label={t('sharedPage.resolvedSource')}
|
||||||
value={selectedItemContent.contentPath}
|
value={selectedItemContent.contentPath}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
@@ -310,12 +327,12 @@ export function SharedPage() {
|
|||||||
<ScrollArea className="h-full px-5 py-4">
|
<ScrollArea className="h-full px-5 py-4">
|
||||||
{isContentLoading ? (
|
{isContentLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Loading markdown content...
|
{t('sharedPage.loadingMarkdown')}
|
||||||
</p>
|
</p>
|
||||||
) : isContentError ? (
|
) : isContentError ? (
|
||||||
<Alert variant="destructive" className="max-w-2xl">
|
<Alert variant="destructive" className="max-w-2xl">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Failed to load content</AlertTitle>
|
<AlertTitle>{t('sharedPage.failedLoadContent')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>{contentErrorMessage}</p>
|
<p>{contentErrorMessage}</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -327,7 +344,7 @@ export function SharedPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry content
|
{t('sharedPage.retryContent')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -433,10 +450,11 @@ interface ParsedMarkdownDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownViewer({ content }: { content: string }) {
|
function MarkdownViewer({ content }: { content: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
|
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
|
||||||
|
|
||||||
if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) {
|
if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">No markdown content available.</p>;
|
return <p className="text-sm text-muted-foreground">{t('sharedPage.noMarkdown')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+13
-15
@@ -17,15 +17,10 @@ import {
|
|||||||
writeNoticeProgressMap,
|
writeNoticeProgressMap,
|
||||||
type NoticeProgressMap,
|
type NoticeProgressMap,
|
||||||
} from '@/lib/updates-notice-state';
|
} from '@/lib/updates-notice-state';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type NoticeViewMode = 'inbox' | 'done' | 'all';
|
type NoticeViewMode = 'inbox' | 'done' | 'all';
|
||||||
|
|
||||||
const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [
|
|
||||||
{ id: 'inbox', label: 'Action Required' },
|
|
||||||
{ id: 'done', label: 'Done' },
|
|
||||||
{ id: 'all', label: 'All' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
|
function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
|
||||||
if (!queryValue) {
|
if (!queryValue) {
|
||||||
return true;
|
return true;
|
||||||
@@ -49,6 +44,7 @@ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpdatesPage() {
|
export function UpdatesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const notices = useMemo(
|
const notices = useMemo(
|
||||||
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
|
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
|
||||||
[]
|
[]
|
||||||
@@ -112,20 +108,18 @@ export function UpdatesPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Megaphone className="h-5 w-5 text-primary" />
|
<Megaphone className="h-5 w-5 text-primary" />
|
||||||
<h1 className="font-semibold">Updates Inbox</h1>
|
<h1 className="font-semibold">{t('updates.inboxTitle')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('updates.inboxSubtitle')}</p>
|
||||||
Focus on actions, then mark updates done or dismissed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div className="rounded-md border bg-background px-2 py-1.5">
|
<div className="rounded-md border bg-background px-2 py-1.5">
|
||||||
<p className="text-muted-foreground">Needs Action</p>
|
<p className="text-muted-foreground">{t('updates.needsAction')}</p>
|
||||||
<p className="text-base font-semibold">{pendingCount}</p>
|
<p className="text-base font-semibold">{pendingCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background px-2 py-1.5">
|
<div className="rounded-md border bg-background px-2 py-1.5">
|
||||||
<p className="text-muted-foreground">Done</p>
|
<p className="text-muted-foreground">{t('updates.doneCount')}</p>
|
||||||
<p className="text-base font-semibold">{doneCount}</p>
|
<p className="text-base font-semibold">{doneCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,13 +129,17 @@ export function UpdatesPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="Search actions or commands"
|
placeholder={t('updates.searchPlaceholder')}
|
||||||
className="h-9 pl-8"
|
className="h-9 pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{NOTICE_VIEW_MODES.map((mode) => (
|
{[
|
||||||
|
{ id: 'inbox' as const, label: t('updates.actionRequired') },
|
||||||
|
{ id: 'done' as const, label: t('updates.done') },
|
||||||
|
{ id: 'all' as const, label: t('updates.all') },
|
||||||
|
].map((mode) => (
|
||||||
<Button
|
<Button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -158,7 +156,7 @@ export function UpdatesPage() {
|
|||||||
<div className="space-y-2 p-2">
|
<div className="space-y-2 p-2">
|
||||||
{visibleNotices.length === 0 ? (
|
{visibleNotices.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
||||||
No notices match this view.
|
{t('updates.noNotices')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleNotices.map((notice) => (
|
visibleNotices.map((notice) => (
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||||
|
import i18n from '@/lib/i18n';
|
||||||
|
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
||||||
|
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
|
||||||
|
import { getInitialLocale, LOCALE_STORAGE_KEY, persistLocale } from '@/lib/locales';
|
||||||
|
|
||||||
|
describe('Dashboard i18n', () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const localStorageMock = window.localStorage as unknown as {
|
||||||
|
getItem: ReturnType<typeof vi.fn>;
|
||||||
|
setItem: ReturnType<typeof vi.fn>;
|
||||||
|
removeItem: ReturnType<typeof vi.fn>;
|
||||||
|
clear: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage.clear();
|
||||||
|
localStorageMock.getItem.mockImplementation((key: string) => storage.get(key) ?? null);
|
||||||
|
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
});
|
||||||
|
localStorageMock.removeItem.mockImplementation((key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
});
|
||||||
|
localStorageMock.clear.mockImplementation(() => {
|
||||||
|
storage.clear();
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(() => false),
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
|
||||||
|
await i18n.changeLanguage('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'renders language switcher and changes locale',
|
||||||
|
async () => {
|
||||||
|
render(<LanguageSwitcher />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('English')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('combobox'));
|
||||||
|
await userEvent.click(await screen.findByText('Simplified Chinese'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(i18n.language).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
|
||||||
|
},
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
|
||||||
|
it('restores locale from persisted storage', () => {
|
||||||
|
persistLocale('zh-CN');
|
||||||
|
expect(getInitialLocale()).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Chinese labels on translated settings tabs', async () => {
|
||||||
|
await i18n.changeLanguage('zh-CN');
|
||||||
|
|
||||||
|
render(<TabNavigation activeTab="websearch" onTabChange={() => {}} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('网页')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('环境')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('认证')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user