From 257af14293d59c3767adb140926e73653c9a3427 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:25:41 -0500 Subject: [PATCH 01/31] chore(deps): update dependencies --- ui/bun.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++-- ui/package.json | 2 ++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index 4a0d6dae..2f28cb7b 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,6 +5,8 @@ "name": "ui", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -201,6 +203,20 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="], + + "@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="], + + "@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="], + + "@nivo/sankey": ["@nivo/sankey@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-sankey": "^0.11.2", "@types/d3-shape": "^3.1.6", "d3-sankey": "^0.12.3", "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw=="], + + "@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="], + + "@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="], + + "@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -279,6 +295,18 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="], + + "@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="], + + "@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="], + + "@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="], + + "@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="], + + "@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], @@ -383,8 +411,12 @@ "@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="], + "@types/d3-sankey": ["@types/d3-sankey@0.11.2", "", { "dependencies": { "@types/d3-shape": "^1" } }, "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], @@ -479,19 +511,23 @@ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + "d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="], "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="], "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], @@ -725,6 +761,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="], + "react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -783,6 +821,8 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="], + "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=="], "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=="], @@ -807,6 +847,10 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@nivo/core/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@nivo/sankey/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -845,12 +889,34 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "d3-scale/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@nivo/core/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@nivo/sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "d3-time-format/d3-time/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "d3-time-format/d3-time/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], } } diff --git a/ui/package.json b/ui/package.json index e44e696d..9c39d5d1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", From b97c3bfda4ad47787fbce53617a7ee5d5267c2bf Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:02 -0500 Subject: [PATCH 02/31] feat(ui): implement auth monitor components & pages --- ui/src/components/account-flow-viz.tsx | 350 ++++++++++++++++++++++ ui/src/components/auth-monitor.tsx | 387 +++++++++++++++++++++++++ ui/src/components/hero-section.tsx | 83 +----- ui/src/components/provider-icon.tsx | 95 ++++++ ui/src/lib/provider-config.ts | 37 +++ ui/src/lib/utils.ts | 52 +++- ui/src/pages/home.tsx | 274 ++++++++--------- 7 files changed, 1041 insertions(+), 237 deletions(-) create mode 100644 ui/src/components/account-flow-viz.tsx create mode 100644 ui/src/components/auth-monitor.tsx create mode 100644 ui/src/components/provider-icon.tsx create mode 100644 ui/src/lib/provider-config.ts diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx new file mode 100644 index 00000000..8ed2a6b0 --- /dev/null +++ b/ui/src/components/account-flow-viz.tsx @@ -0,0 +1,350 @@ +/** + * Account Flow Visualization + * Custom SVG bezier curve visualization showing request flow from accounts to providers + * Inspired by modern dark theme design with glass panels and glow effects + */ + +import { useRef, useEffect, useState, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ProviderIcon } from '@/components/provider-icon'; +import { PROVIDER_COLORS } from '@/lib/provider-config'; +import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react'; + +interface AccountData { + id: string; + email: string; + provider: string; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +interface ProviderData { + provider: string; + displayName: string; + totalRequests: number; + accounts: AccountData[]; +} + +interface AccountFlowVizProps { + providerData: ProviderData; + onBack?: () => void; +} + +/** Strip common email domains for cleaner display */ +function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +function getTimeAgo(dateStr?: string): string { + if (!dateStr) return 'never'; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return 'unknown'; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + if (diffMs < 0) return 'just now'; + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [hoveredAccount, setHoveredAccount] = useState(null); + const [selectedAccount, setSelectedAccount] = useState(null); + const [paths, setPaths] = useState([]); + + const { accounts } = providerData; + const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); + const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + + // Calculate SVG paths for bezier curves + const calculatePaths = useCallback(() => { + if (!containerRef.current || !svgRef.current) return; + + const container = containerRef.current; + const svg = svgRef.current; + const svgRect = svg.getBoundingClientRect(); + + const destEl = container.querySelector('[data-provider-node]'); + if (!destEl) return; + const destRect = destEl.getBoundingClientRect(); + + // Destination point (left center of provider card) + const destX = destRect.left - svgRect.left; + const destY = destRect.top + destRect.height / 2 - svgRect.top; + + const newPaths: string[] = []; + + accounts.forEach((_, i) => { + const sourceEl = container.querySelector(`[data-account-index="${i}"]`); + if (!sourceEl) return; + const sourceRect = sourceEl.getBoundingClientRect(); + + // Source point (right center of account card) + const startX = sourceRect.right - svgRect.left; + const startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + + // Bezier control points + const cp1X = startX + (destX - startX) * 0.5; + const cp1Y = startY; + const cp2X = destX - (destX - startX) * 0.5; + const cp2Y = destY; + + newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); + }); + + setPaths(newPaths); + }, [accounts]); + + useEffect(() => { + // Initial calculation after render + const timer = setTimeout(calculatePaths, 50); + window.addEventListener('resize', calculatePaths); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', calculatePaths); + }; + }, [calculatePaths]); + + const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; + + return ( +
+ {/* Back button */} + {onBack && ( + + )} + + {/* Main visualization area */} +
+ {/* SVG Canvas (Background) */} + + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + + return ( + + ); + })} + + + {/* Left Column: Source Accounts */} +
+ {accounts.map((account, i) => { + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === i; + + return ( +
setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(i)} + onMouseLeave={() => setHoveredAccount(null)} + className={cn( + 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border border-border/50 dark:border-white/[0.08]', + 'border-l-2 hover:translate-x-1', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60' + )} + style={{ borderLeftColor: account.color }} + > +
+ + {cleanEmail(account.email)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+ {/* Connector Dot */} +
+
+ ); + })} +
+ + {/* Right Column: Destination Provider */} +
+
+ {/* Connector Point */} +
+ +
+ +
+

+ {providerData.displayName} +

+

Provider

+
+
+ +
+
+ Total Requests + {totalRequests.toLocaleString()} +
+
+ Accounts + {accounts.length} +
+
+
+
+
+
+
+
+ + {/* Detail Panel (Bottom slide-up) */} +
+
+ + + {selectedAccount && ( +
+ {/* Account Info */} +
+
+
+ + {cleanEmail(selectedAccount.email)} + +
+
+ Source Account +
+
+ + {/* Stats */} +
+
+ + SUCCESSFUL +
+
+ {selectedAccount.successCount.toLocaleString()} +
+
+ +
+
+ + FAILED +
+
+ {selectedAccount.failureCount.toLocaleString()} +
+
+ +
+
+ + LAST SYNC +
+
+ {getTimeAgo(selectedAccount.lastUsedAt)} +
+
+
+ )} +
+
+
+ ); +} diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx new file mode 100644 index 00000000..26d83769 --- /dev/null +++ b/ui/src/components/auth-monitor.tsx @@ -0,0 +1,387 @@ +/** + * Auth Monitor Component with Account Flow Visualization + * Shows request flow from accounts to providers using custom SVG bezier curves + * Uses glass panel aesthetic with hover interactions and glow effects + */ + +import { useState, useMemo } from 'react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { cn, STATUS_COLORS } from '@/lib/utils'; +import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ProviderIcon } from '@/components/provider-icon'; +import { AccountFlowViz } from '@/components/account-flow-viz'; +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import { Activity, CheckCircle2, XCircle, ChevronRight } from 'lucide-react'; + +interface AccountRow { + id: string; + email: string; + provider: string; + displayName: string; + isDefault: boolean; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +interface ProviderStats { + provider: string; + displayName: string; + totalRequests: number; + successCount: number; + failureCount: number; + accountCount: number; + accounts: AccountRow[]; +} + +function getSuccessRate(success: number, failure: number): number { + const total = success + failure; + if (total === 0) return 100; + return Math.round((success / total) * 100); +} + +/** Strip common email domains for cleaner display */ +function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +// Vibrant colors for account segments +const ACCOUNT_COLORS = [ + '#277da1', // Cerulean + '#43aa8b', // Seaweed + '#f9c74f', // Tuscan Sun + '#f94144', // Strawberry + '#f3722c', // Pumpkin + '#90be6d', // Willow + '#577590', // Blue Slate + '#f8961e', // Carrot + '#4d908e', // Dark Cyan + '#a78bfa', // Purple +]; + +export function AuthMonitor() { + const { data, isLoading, error } = useCliproxyAuth(); + const [selectedProvider, setSelectedProvider] = useState(null); + const [hoveredProvider, setHoveredProvider] = useState(null); + + // Transform auth status data into account rows + const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { + if (!data?.authStatus) { + return { + accounts: [], + totalSuccess: 0, + totalFailure: 0, + totalRequests: 0, + providerStats: [], + }; + } + + const accountsList: AccountRow[] = []; + const providerMap = new Map< + string, + { success: number; failure: number; accounts: AccountRow[] } + >(); + let tSuccess = 0; + let tFailure = 0; + let colorIndex = 0; + + data.authStatus.forEach((status: AuthStatus) => { + const providerKey = status.provider; + if (!providerMap.has(providerKey)) { + providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] }); + } + const providerData = providerMap.get(providerKey); + if (!providerData) return; + + status.accounts?.forEach((account: OAuthAccount) => { + // Mock stats - in production, fetch from CLIProxy /usage endpoint + const success = Math.floor(Math.random() * 2000) + 100; + const failure = account.isDefault ? Math.floor(Math.random() * 50) : 0; + tSuccess += success; + tFailure += failure; + providerData.success += success; + providerData.failure += failure; + + const row: AccountRow = { + id: account.id, + email: account.email || account.id, + provider: status.provider, + displayName: status.displayName, + isDefault: account.isDefault, + successCount: success, + failureCount: failure, + lastUsedAt: account.lastUsedAt, + color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], + }; + accountsList.push(row); + providerData.accounts.push(row); + colorIndex++; + }); + }); + + // Build provider stats array + const providerStatsArr: ProviderStats[] = []; + providerMap.forEach((pData, provider) => { + if (pData.accounts.length === 0) return; + providerStatsArr.push({ + provider, + displayName: getProviderDisplayName(provider), + totalRequests: pData.success + pData.failure, + successCount: pData.success, + failureCount: pData.failure, + accountCount: pData.accounts.length, + accounts: pData.accounts, + }); + }); + providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests); + + return { + accounts: accountsList, + totalSuccess: tSuccess, + totalFailure: tFailure, + totalRequests: tSuccess + tFailure, + providerStats: providerStatsArr, + }; + }, [data?.authStatus]); + + const overallSuccessRate = + totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; + + // Get selected provider data for detail view + const selectedProviderData = selectedProvider + ? providerStats.find((ps) => ps.provider === selectedProvider) + : null; + + if (isLoading) { + return ( +
+
+ + +
+
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ +
+
+ ); + } + + if (error || !data?.authStatus || accounts.length === 0) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+ + Live Stream + +
+
+ {accounts.length} accounts + {totalRequests.toLocaleString()} req +
+
+ + {/* Summary Stats Row */} +
+ } + label="Accounts" + value={accounts.length} + color="var(--accent)" + /> + } + label="Success" + value={totalSuccess.toLocaleString()} + color={STATUS_COLORS.success} + /> + } + label="Failed" + value={totalFailure.toLocaleString()} + color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} + /> + } + label="Success Rate" + value={`${overallSuccessRate}%`} + color={ + overallSuccessRate === 100 + ? STATUS_COLORS.success + : overallSuccessRate >= 95 + ? STATUS_COLORS.degraded + : STATUS_COLORS.failed + } + /> +
+ + {/* Flow Visualization */} +
+ {selectedProviderData ? ( + // Account-level flow view + setSelectedProvider(null)} + /> + ) : ( + // Provider cards view +
+
+ Request Distribution by Provider +
+
+ {providerStats.map((ps) => { + const successRate = getSuccessRate(ps.successCount, ps.failureCount); + const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280'; + const isHovered = hoveredProvider === ps.provider; + + return ( + + ); + })} +
+
+ )} +
+
+ ); +} + +// Summary Card Component +function SummaryCard({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + color?: string; +}) { + return ( +
+
+ {icon} +
+
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/ui/src/components/hero-section.tsx b/ui/src/components/hero-section.tsx index 17418826..47b49b91 100644 --- a/ui/src/components/hero-section.tsx +++ b/ui/src/components/hero-section.tsx @@ -1,85 +1,22 @@ import { Badge } from '@/components/ui/badge'; import { CcsLogo } from '@/components/ccs-logo'; -import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react'; -import { cn } from '@/lib/utils'; interface HeroSectionProps { version?: string; - healthStatus?: 'ok' | 'warning' | 'error'; - healthPassed?: number; - healthTotal?: number; } -const statusConfig = { - ok: { - icon: CheckCircle2, - label: 'All Systems Operational', - color: 'text-green-600', - badgeBg: 'bg-green-500/10 text-green-600 border-green-500/20', - }, - warning: { - icon: AlertCircle, - label: 'Some Issues Detected', - color: 'text-yellow-500', - badgeBg: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', - }, - error: { - icon: XCircle, - label: 'Action Required', - color: 'text-red-500', - badgeBg: 'bg-red-500/10 text-red-500 border-red-500/20', - }, -}; - -export function HeroSection({ - version = '5.0.0', - healthStatus = 'ok', - healthPassed = 0, - healthTotal = 0, -}: HeroSectionProps) { - const status = statusConfig[healthStatus]; - const StatusIcon = status.icon; - +export function HeroSection({ version = '5.0.0' }: HeroSectionProps) { return ( -
- {/* Subtle background pattern */} -
-
-
- -
- {/* Left: Logo and Welcome */} -
- -
-
-

CCS Config

- - v{version} - -
-

Claude Code Switch Dashboard

-
-
- - {/* Right: Health Status */} -
- -
-

{status.label}

- {healthTotal > 0 && ( -

- {healthPassed}/{healthTotal} checks passed -

- )} -
+
+ +
+
+

CCS Config

+ + v{version} +
+

Claude Code Switch Dashboard

); diff --git a/ui/src/components/provider-icon.tsx b/ui/src/components/provider-icon.tsx new file mode 100644 index 00000000..828f649e --- /dev/null +++ b/ui/src/components/provider-icon.tsx @@ -0,0 +1,95 @@ +/** + * Provider Icon Component + * Renders provider logos from /assets/providers/ + * Supports white background circle variant for dark themes + */ + +import { cn } from '@/lib/utils'; +import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config'; + +interface ProviderIconProps { + provider: string; + className?: string; + size?: number; + /** White background circle variant for better visibility */ + withBackground?: boolean; +} + +export function ProviderIcon({ + provider, + className, + size = 18, + withBackground = false, +}: ProviderIconProps) { + const normalized = provider.toLowerCase(); + const assetPath = PROVIDER_ASSETS[normalized]; + + // Icon size is smaller when inside background circle + const iconSize = withBackground ? Math.floor(size * 0.65) : size; + + const iconElement = assetPath ? ( + {`${provider} + ) : ( + // Fallback: colored text letter + + {provider.charAt(0).toUpperCase()} + + ); + + if (withBackground) { + return ( +
+ {iconElement} +
+ ); + } + + // Without background - original behavior for logos, colored circle for fallback + if (assetPath) { + return ( + {`${provider} + ); + } + + const bgColor = PROVIDER_COLORS[normalized] || '#6b7280'; + return ( +
+ {provider.charAt(0).toUpperCase()} +
+ ); +} diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts new file mode 100644 index 00000000..299e8145 --- /dev/null +++ b/ui/src/lib/provider-config.ts @@ -0,0 +1,37 @@ +/** + * Provider Configuration + * Shared constants for provider branding and assets + */ + +// Map provider names to asset filenames (only providers with actual logos) +export const PROVIDER_ASSETS: Record = { + gemini: '/assets/providers/gemini-color.svg', + agy: '/assets/providers/agy.png', + codex: '/assets/providers/openai.svg', + qwen: '/assets/providers/qwen-color.svg', +}; + +// Provider brand colors +export const PROVIDER_COLORS: Record = { + gemini: '#4285F4', + agy: '#f3722c', + codex: '#10a37f', + vertex: '#4285F4', + iflow: '#f94144', + qwen: '#6236FF', +}; + +// Provider display names +const PROVIDER_NAMES: Record = { + gemini: 'Gemini', + agy: 'Antigravity', + codex: 'Codex', + vertex: 'Vertex AI', + iflow: 'iFlow', + qwen: 'Qwen', +}; + +// Map provider to display name +export function getProviderDisplayName(provider: string): string { + return PROVIDER_NAMES[provider.toLowerCase()] || provider; +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index d683130e..417ed674 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -5,21 +5,38 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function getModelColor(model: string): string { - // Vibrant Tones Palette - const colors = [ - '#f94144', // Strawberry Red - '#f3722c', // Pumpkin Spice - '#f8961e', // Carrot Orange - '#f9844a', // Atomic Tangerine - '#f9c74f', // Tuscan Sun - '#90be6d', // Willow Green - '#43aa8b', // Seaweed - '#4d908e', // Dark Cyan - '#577590', // Blue Slate - '#277da1', // Cerulean - ]; +// Vibrant Tones Palette +const VIBRANT_TONES = [ + '#f94144', // Strawberry Red + '#f3722c', // Pumpkin Spice + '#f8961e', // Carrot Orange + '#f9844a', // Atomic Tangerine + '#f9c74f', // Tuscan Sun + '#90be6d', // Willow Green + '#43aa8b', // Seaweed + '#4d908e', // Dark Cyan + '#577590', // Blue Slate + '#277da1', // Cerulean +]; +// Provider color mapping (fixed colors for consistency) +const PROVIDER_COLORS: Record = { + agy: '#f3722c', // Pumpkin + gemini: '#277da1', // Cerulean + codex: '#f8961e', // Carrot + vertex: '#577590', // Blue Slate + iflow: '#f94144', // Strawberry + qwen: '#f9c74f', // Tuscan +}; + +// Status colors (from Analytics Cost breakdown) +export const STATUS_COLORS = { + success: '#43aa8b', // Seaweed + degraded: '#e09f3e', // Ochre + failed: '#9e2a2b', // Merlot +} as const; + +export function getModelColor(model: string): string { // FNV-1a hash algorithm let hash = 0x811c9dc5; for (let i = 0; i < model.length; i++) { @@ -28,5 +45,10 @@ export function getModelColor(model: string): string { } // Ensure positive index - return colors[(hash >>> 0) % colors.length]; + return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length]; +} + +export function getProviderColor(provider: string): string { + const normalized = provider.toLowerCase(); + return PROVIDER_COLORS[normalized] || getModelColor(provider); } diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 31b2bcb7..1a8adc5c 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,25 +1,13 @@ import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { StatCard } from '@/components/stat-card'; import { HeroSection } from '@/components/hero-section'; -import { QuickCommands } from '@/components/quick-commands'; +import { AuthMonitor } from '@/components/auth-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; -import { - Key, - Zap, - Users, - Activity, - Plus, - Stethoscope, - BookOpen, - FolderOpen, - AlertTriangle, - ArrowRight, -} from 'lucide-react'; +import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; +import { cn } from '@/lib/utils'; +import type { LucideIcon } from 'lucide-react'; const HEALTH_VARIANTS = { ok: 'success', @@ -27,6 +15,51 @@ const HEALTH_VARIANTS = { error: 'error', } as const; +type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent'; + +const variantStyles: Record = { + default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' }, + success: { iconBg: 'bg-green-500/10', iconColor: 'text-green-600' }, + warning: { iconBg: 'bg-yellow-500/10', iconColor: 'text-yellow-500' }, + error: { iconBg: 'bg-red-500/10', iconColor: 'text-red-500' }, + accent: { iconBg: 'bg-accent/10', iconColor: 'text-accent' }, +}; + +function InlineStat({ + title, + value, + icon: Icon, + variant = 'default', + onClick, +}: { + title: string; + value: number | string; + icon: LucideIcon; + variant?: StatVariant; + onClick?: () => void; +}) { + const styles = variantStyles[variant]; + + return ( + + ); +} + export function HomePage() { const navigate = useNavigate(); const { data: overview, isLoading: isOverviewLoading } = useOverview(); @@ -35,8 +68,8 @@ export function HomePage() { if (isOverviewLoading || isSharedLoading) { return (
- {/* Hero Skeleton */} -
+ {/* Hero Row Skeleton */} +
@@ -44,42 +77,31 @@ export function HomePage() {
-
- - {/* Stats Skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
-
- -
- - -
-
-
- ))} -
- - {/* Quick Actions Skeleton */} -
- -
- - - -
-
- - {/* Quick Commands Skeleton */} -
- -
+
{[1, 2, 3, 4].map((i) => ( - + ))}
+ + {/* Auth Monitor Skeleton */} +
+
+ + +
+
+ +
+ {[1, 2, 3].map((i) => ( +
+ + + + +
+ ))} +
); } @@ -90,13 +112,57 @@ export function HomePage() { return (
- {/* Hero Section */} - + {/* Hero Row: Logo/Title + Inline Stats */} +
+ {/* Subtle background pattern */} +
+
+
+ + {/* Single Row Layout */} +
+ {/* Left: Logo + Title */} + + + {/* Right: Inline Stats */} +
+ navigate('/api')} + /> + navigate('/cliproxy')} + /> + navigate('/accounts')} + /> + navigate('/health')} + /> +
+
+
{/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( @@ -107,98 +173,8 @@ export function HomePage() { )} - {/* Stats Grid */} -
- navigate('/api')} - /> - navigate('/cliproxy')} - /> - navigate('/accounts')} - /> - navigate('/health')} - /> -
- - {/* Quick Actions */} - - - Quick Actions - - - - - - - - - {/* Quick Commands */} - - - {/* Shared Data Summary */} - - - - - Shared Data - - - - -
-
- {shared?.commands ?? 0} - Commands -
-
- {shared?.skills ?? 0} - Skills -
-
- {shared?.agents ?? 0} - Agents -
-
-
-
+ {/* Auth Monitor */} +
); } From 127e0e60437f47cd445c1ce9ddf03415d51a06c7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:21 -0500 Subject: [PATCH 03/31] feat(web): update account manager and web routes --- src/cliproxy/account-manager.ts | 4 +++- src/web-server/routes.ts | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index b1fb5dc7..23ffbb7d 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -473,12 +473,14 @@ export function discoverExistingAccounts(): void { const stats = fs.statSync(filePath); // Register account with auto-generated nickname + // Use mtime as lastUsedAt (when token was last modified = last auth/refresh) + const lastModified = stats.mtime || stats.birthtime || new Date(); providerAccounts.accounts[accountId] = { email, nickname: generateNickname(email), tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), - lastUsedAt: stats.mtime?.toISOString(), + lastUsedAt: lastModified.toISOString(), }; } catch { // Skip invalid files diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 8108563e..ccde0ab3 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -36,6 +36,7 @@ import { getProviderAccounts, setDefaultAccount as setDefaultAccountFn, removeAccount as removeAccountFn, + touchAccount, } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } from '../cliproxy/config-generator'; @@ -469,11 +470,43 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => { /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles + * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ -apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => { +apiRoutes.get('/cliproxy/auth', async (_req: Request, res: Response) => { // Initialize accounts from existing tokens on first request initializeAccounts(); + // Fetch CLIProxyAPI usage stats to determine active providers + const stats = await fetchCliproxyStats(); + + // Map CLIProxyAPI provider names to our internal provider names + const statsProviderMap: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', + }; + + // Update lastUsedAt for providers with recent activity + if (stats?.requestsByProvider) { + for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { + if (requestCount > 0) { + const provider = statsProviderMap[statsProvider.toLowerCase()]; + if (provider) { + // Touch the default account for this provider (or all accounts) + const accounts = getProviderAccounts(provider); + for (const account of accounts) { + // Only touch if this is the default account (most likely being used) + if (account.isDefault) { + touchAccount(provider, account.id); + } + } + } + } + } + } + const statuses = getAllAuthStatus(); const authStatus = statuses.map((status) => { From 349d81a1b0b38a55ad4f245ec88552e5b1ba4359 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 14 Dec 2025 04:26:31 -0500 Subject: [PATCH 04/31] chore(ui): update ui build config and typings --- ui/src/vite-env.d.ts | 1 + ui/tsconfig.app.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 ui/src/vite-env.d.ts diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 2ad9a724..99fbd39c 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -5,7 +5,6 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ From 27de6af8aa5a5de72b2c34137cd1a5343e175bc9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 15 Dec 2025 01:23:26 -0500 Subject: [PATCH 05/31] feat(ui): add connection timeline and improve account flow layout - Add ConnectionTimeline component showing recent connection events - Restructure layout to 2-column design: flow viz (flex) | timeline (fixed) - Flow chart now expands to fill available space with justify-between - Timeline has compact fixed width (w-56) on the right - Detail panel now uses flow-based positioning instead of absolute - Generate mock connection events based on account success/failure data --- ui/src/components/account-flow-viz.tsx | 550 ++++++++++++++++--------- 1 file changed, 359 insertions(+), 191 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 8ed2a6b0..3951dd40 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -4,11 +4,12 @@ * Inspired by modern dark theme design with glass panels and glow effects */ -import { useRef, useEffect, useState, useCallback } from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/provider-icon'; import { PROVIDER_COLORS } from '@/lib/provider-config'; -import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react'; +import { STATUS_COLORS } from '@/lib/utils'; +import { ChevronRight, X, CheckCircle2, XCircle, Clock, Activity } from 'lucide-react'; interface AccountData { id: string; @@ -32,6 +33,157 @@ interface AccountFlowVizProps { onBack?: () => void; } +interface ConnectionEvent { + id: string; + timestamp: Date; + accountEmail: string; + status: 'success' | 'failed' | 'pending'; + latencyMs?: number; +} + +/** Generate mock connection events based on account data */ +function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { + const events: ConnectionEvent[] = []; + const now = new Date(); + + accounts.forEach((account) => { + // Generate events based on success/failure counts + const successEvents = Math.min(account.successCount, 3); + const failEvents = Math.min(account.failureCount, 2); + + for (let i = 0; i < successEvents; i++) { + events.push({ + id: `${account.id}-s-${i}`, + timestamp: new Date(now.getTime() - Math.random() * 3600000), // Last hour + accountEmail: account.email, + status: 'success', + latencyMs: Math.floor(Math.random() * 500) + 50, + }); + } + + for (let i = 0; i < failEvents; i++) { + events.push({ + id: `${account.id}-f-${i}`, + timestamp: new Date(now.getTime() - Math.random() * 7200000), // Last 2 hours + accountEmail: account.email, + status: 'failed', + }); + } + }); + + // Sort by timestamp descending (most recent first) + return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); +} + +/** Format timestamp for timeline display */ +function formatTimelineTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'now'; + if (diffMins < 60) return `${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.floor(diffHours / 24)}d`; +} + +/** Connection Timeline Component - right sidebar panel */ +function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) { + if (events.length === 0) { + return ( +
+
No recent connections
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Connection Timeline + +
+ + {/* Timeline container */} +
+
+ {/* Vertical line */} +
+ + {/* Events */} +
+ {events.slice(0, 8).map((event) => { + const statusColor = + event.status === 'success' + ? STATUS_COLORS.success + : event.status === 'failed' + ? STATUS_COLORS.failed + : STATUS_COLORS.degraded; + + return ( +
+ {/* Timeline dot */} +
+ + {/* Event content */} +
+
+ + {cleanEmail(event.accountEmail)} + + + {formatTimelineTime(event.timestamp)} + +
+
+ + {event.status} + + {event.latencyMs && ( + + {event.latencyMs}ms + + )} +
+
+
+ ); + })} +
+ + {/* Show more indicator */} + {events.length > 8 && ( +
+ + +{events.length - 8} more events + +
+ )} +
+
+
+ ); +} + /** Strip common email domains for cleaner display */ function cleanEmail(email: string): string { return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); @@ -64,6 +216,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + // Generate connection events for timeline + const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]); + // Calculate SVG paths for bezier curves const calculatePaths = useCallback(() => { if (!containerRef.current || !svgRef.current) return; @@ -116,233 +271,246 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; return ( -
+
{/* Back button */} {onBack && ( )} - {/* Main visualization area */} -
- {/* SVG Canvas (Background) */} - - - - - - - - {paths.map((d, i) => { - const account = accounts[i]; - const total = account.successCount + account.failureCount; - const strokeWidth = Math.max(2, (total / maxRequests) * 10); - const isHovered = hoveredAccount === i; - const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + {/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */} +
+ {/* Left Section: Account → Provider Flow - expands to fill available space */} +
+ {/* SVG Canvas (Background) */} + + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; - return ( - - ); - })} - + return ( + + ); + })} + - {/* Left Column: Source Accounts */} -
- {accounts.map((account, i) => { - const total = account.successCount + account.failureCount; - const isHovered = hoveredAccount === i; + {/* Source Accounts */} +
+ {accounts.map((account, i) => { + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === i; - return ( -
setSelectedAccount(account)} - onMouseEnter={() => setHoveredAccount(i)} - onMouseLeave={() => setHoveredAccount(null)} - className={cn( - 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-l-2 hover:translate-x-1', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60' - )} - style={{ borderLeftColor: account.color }} - > -
- - {cleanEmail(account.email)} - - setSelectedAccount(account)} + onMouseEnter={() => setHoveredAccount(i)} + onMouseLeave={() => setHoveredAccount(null)} + className={cn( + 'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300', + 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', + 'border border-border/50 dark:border-white/[0.08]', + 'border-l-2 hover:translate-x-1', + isHovered && 'bg-muted/50 dark:bg-zinc-800/60' + )} + style={{ borderLeftColor: account.color }} + > +
+ + {cleanEmail(account.email)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+ {/* Connector Dot */} +
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
-
- {/* Connector Dot */} -
-
- ); - })} -
+ ); + })} +
- {/* Right Column: Destination Provider */} -
-
- {/* Connector Point */} + {/* Destination Provider */} +
+ > + {/* Connector Point */} +
-
- -
-

- {providerData.displayName} -

-

Provider

+
+ +
+

+ {providerData.displayName} +

+

+ Provider +

+
-
-
-
- Total Requests - {totalRequests.toLocaleString()} -
-
- Accounts - {accounts.length} -
-
-
+
+
+ Total Requests + + {totalRequests.toLocaleString()} + +
+
+ Accounts + {accounts.length} +
+
+
+
+ + {/* Right Section: Connection Timeline - Fixed compact width */} +
+ +
- {/* Detail Panel (Bottom slide-up) */} + {/* Detail Panel - slides in from bottom, pushes content */}
-
- +
+
+ - {selectedAccount && ( -
- {/* Account Info */} -
-
-
- - {cleanEmail(selectedAccount.email)} - + {selectedAccount && ( +
+ {/* Account Info */} +
+
+
+ + {cleanEmail(selectedAccount.email)} + +
+
+ Source Account +
-
- Source Account + + {/* Stats */} +
+
+ + SUCCESSFUL +
+
+ {selectedAccount.successCount.toLocaleString()} +
+
+ +
+
+ + FAILED +
+
+ {selectedAccount.failureCount.toLocaleString()} +
+
+ +
+
+ + LAST SYNC +
+
+ {getTimeAgo(selectedAccount.lastUsedAt)} +
- - {/* Stats */} -
-
- - SUCCESSFUL -
-
- {selectedAccount.successCount.toLocaleString()} -
-
- -
-
- - FAILED -
-
- {selectedAccount.failureCount.toLocaleString()} -
-
- -
-
- - LAST SYNC -
-
- {getTimeAgo(selectedAccount.lastUsedAt)} -
-
-
- )} + )} +
From 6e4ee805da23ac8969f4161d27047b5b82a07abc Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 15 Dec 2025 16:14:59 -0500 Subject: [PATCH 06/31] feat(ui): add documentation button to header --- ui/src/components/docs-link.tsx | 20 ++++++++++++++++++++ ui/src/components/layout.tsx | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 ui/src/components/docs-link.tsx diff --git a/ui/src/components/docs-link.tsx b/ui/src/components/docs-link.tsx new file mode 100644 index 00000000..72e16014 --- /dev/null +++ b/ui/src/components/docs-link.tsx @@ -0,0 +1,20 @@ +/** + * Docs Link Button + * + * Links to CCS documentation site for guides and reference. + */ + +import { BookOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCS_URL = 'https://docs.ccs.kaitran.ca'; + +export function DocsLink() { + return ( + + ); +} diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index fba044b2..2872d377 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -4,6 +4,7 @@ import { SidebarProvider } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { ThemeToggle } from '@/components/theme-toggle'; import { GitHubLink } from '@/components/github-link'; +import { DocsLink } from '@/components/docs-link'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; @@ -26,6 +27,7 @@ export function Layout() {
CCS Config
+
From 071ec041ed7e2cfa21cadb7f55bfa93d9fe8cb1b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 00:42:13 -0500 Subject: [PATCH 07/31] feat(websearch): add multi-tier MCP fallback for third-party profiles Implements CCS WebSearch Native Infrastructure (Phases 1-3): Phase 1 - Multi-tier MCP Fallback: - Add mcp-manager.ts with ensureMcpWebSearch() for auto-configuration - Support web-search-prime (primary), Brave Search, and Tavily MCPs - Case-insensitive detection of existing web search MCPs - Block-websearch hook for optional native WebSearch blocking Phase 2 - User Configuration: - Add WebSearchConfig interface to unified-config-types.ts - Add getWebSearchConfig() to unified-config-loader.ts - Support configurable webSearchPrimeUrl for security - Add GET/PUT /api/websearch endpoints in routes.ts - Add WebSearch settings UI in settings.tsx dashboard Phase 3 - Documentation & Testing: - Add WebSearch section to README.md - Create comprehensive docs/websearch.md guide - Add 23 unit tests for MCP manager logic Enables web search for gemini, codex, agy, qwen, glm, kimi profiles that cannot access Anthropic's native WebSearch API. --- README.md | 39 +++ docs/websearch.md | 148 +++++++++++ lib/hooks/block-websearch.cjs | 75 ++++++ src/ccs.ts | 5 + src/cliproxy/cliproxy-executor.ts | 6 + src/config/unified-config-loader.ts | 46 +++- src/config/unified-config-types.ts | 22 ++ src/utils/mcp-manager.ts | 384 ++++++++++++++++++++++++++++ src/web-server/routes.ts | 78 ++++++ tests/unit/mcp-manager.test.ts | 269 +++++++++++++++++++ ui/src/pages/settings.tsx | 271 ++++++++++++++++++-- 11 files changed, 1314 insertions(+), 29 deletions(-) create mode 100644 docs/websearch.md create mode 100644 lib/hooks/block-websearch.cjs create mode 100644 src/utils/mcp-manager.ts create mode 100644 tests/unit/mcp-manager.test.ts diff --git a/README.md b/README.md index f1693ba8..bb69dc9c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
+## WebSearch + +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. + +### How It Works + +| Profile Type | WebSearch Method | +|--------------|------------------| +| Claude (native) | Anthropic WebSearch API | +| OAuth providers | MCP web-search-prime (auto-configured) | +| API profiles | MCP web-search-prime (auto-configured) | + +### Configuration + +Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable/disable auto-config + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain +``` + +### Optional API Keys + +For additional search providers, set environment variables: + +```bash +export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month +export TAVILY_API_KEY="your-key" # AI-optimized search (paid) +``` + +> [!NOTE] +> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks. + +See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. + +
+ ## Documentation | Topic | Link | diff --git a/docs/websearch.md b/docs/websearch.md new file mode 100644 index 00000000..c1a02392 --- /dev/null +++ b/docs/websearch.md @@ -0,0 +1,148 @@ +# WebSearch Configuration Guide + +CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. + +## How WebSearch Works + +### Native Claude Accounts + +When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing). + +### Third-Party Profiles + +Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because: +- Claude Code CLI executes tools locally +- CLIProxyAPI only receives conversation messages +- Tool execution never reaches the third-party backend + +CCS solves this by automatically configuring MCP (Model Context Protocol) web search servers. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Claude Code CLI │ +│ │ +│ WebSearch Tool Request │ +│ │ │ +│ ├── Native Claude Account? → Anthropic WebSearch API │ +│ │ ($10/1000 searches) │ +│ │ │ +│ └── Third-party Profile? → MCP Fallback Chain │ +│ │ │ +│ ├── 1. web-search-prime │ +│ ├── 2. Brave Search (free) │ +│ └── 3. Tavily (paid) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## MCP Providers + +| Provider | Type | Cost | API Key Required | Notes | +|----------|------|------|------------------|-------| +| web-search-prime | HTTP MCP | Free | No | Primary, always available | +| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | +| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | + +## Configuration + +### Via Dashboard + +1. Open dashboard: `ccs config` +2. Navigate to **Settings** page +3. Configure WebSearch options: + - **Enable/Disable**: Toggle auto-configuration + - **Provider**: Choose preferred provider + - **Fallback**: Enable/disable fallback chain + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable auto-config (default: true) + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain (default: true) + webSearchPrimeUrl: "https://..." # Optional: custom endpoint +``` + +### Provider Options + +- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available +- **web-search-prime**: Free, no API key needed +- **brave**: Requires `BRAVE_API_KEY` env var +- **tavily**: Requires `TAVILY_API_KEY` env var + +## Setting Up Optional Providers + +### Brave Search (Free Tier) + +1. Get API key: [brave.com/search/api](https://brave.com/search/api) +2. Set environment variable: + ```bash + export BRAVE_API_KEY="your-api-key" + ``` +3. Restart CCS - Brave will be added to fallback chain + +**Free tier limits**: 15,000 queries/month, 1 query/second + +### Tavily (AI-Optimized) + +1. Get API key: [tavily.com](https://tavily.com) +2. Set environment variable: + ```bash + export TAVILY_API_KEY="your-api-key" + ``` +3. Restart CCS - Tavily will be added to fallback chain + +## MCP Configuration + +CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: + +```json +{ + "mcpServers": { + "web-search-prime": { + "type": "http", + "url": "https://api.z.ai/api/mcp/web_search_prime/mcp", + "headers": {} + }, + "brave-search": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "env": { "BRAVE_API_KEY": "..." } + } + } +} +``` + +## Troubleshooting + +### WebSearch Not Working + +1. **Check config**: Ensure `websearch.enabled: true` in config +2. **Verify MCP**: Check `~/.claude/.mcp.json` exists +3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output + +### MCP Server Errors + +1. **Network issues**: web-search-prime requires internet access +2. **npx failures**: Brave/Tavily require Node.js and npx +3. **API key issues**: Verify env vars are set correctly + +### Existing MCP Config + +CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them. + +To reset: +1. Remove web search entries from `~/.claude/.mcp.json` +2. Run any CCS third-party profile to regenerate + +## Security Considerations + +- API keys are stored in environment variables only +- Never commit API keys to version control +- Use `.env` files with proper permissions (chmod 600) +- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys) diff --git a/lib/hooks/block-websearch.cjs b/lib/hooks/block-websearch.cjs new file mode 100644 index 00000000..0c2de5e8 --- /dev/null +++ b/lib/hooks/block-websearch.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Blocking Hook + * + * Blocks Claude's native WebSearch tool and redirects to MCP alternative. + * This is a PreToolUse hook that runs BEFORE the tool is executed. + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "WebSearch", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/block-websearch.cjs", + * "timeout": 5 + * }] + * }] + * } + * } + * + * Exit codes: + * 0 - Allow tool (pass-through) + * 2 - Block tool (deny with message) + * + * @module hooks/block-websearch + */ + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + + // Only block WebSearch tool + if (data.tool_name === 'WebSearch') { + const query = data.tool_input?.query || ''; + + const output = { + decision: 'block', + reason: 'WebSearch unavailable with current provider', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `WebSearch is not available with third-party providers. Use mcp__web-search-prime__webSearchPrime tool instead with the same query: "${query}"`, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); // Exit code 2 = block + } + + // Allow all other tools + process.exit(0); + } catch (err) { + // Don't block on parse errors - allow tool to proceed + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + process.exit(0); + } +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); diff --git a/src/ccs.ts b/src/ccs.ts index 15c57273..0ad69fe5 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,7 @@ import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { ensureMcpWebSearch } from './utils/mcp-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -378,6 +379,10 @@ async function main(): Promise { const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath }); } else if (profileInfo.type === 'settings') { + // Settings-based profiles (glm, glmt, kimi) are third-party providers + // WebSearch is server-side tool - third-party providers have no access + ensureMcpWebSearch(); + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a14ac2c8..a7ae91ac 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -39,6 +39,7 @@ import { } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { ensureMcpWebSearch } from '../utils/mcp-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -112,6 +113,11 @@ export async function execClaudeWithCLIProxy( } }; + // Ensure MCP web-search is configured for third-party profiles + // WebSearch is a server-side tool executed by Anthropic's API + // Third-party providers don't have access, so we use MCP fallback + ensureMcpWebSearch(); + // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 0b4152bb..2b181ba0 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -115,6 +115,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { ...defaults.preferences, ...partial.preferences, }, + websearch: { + enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, + provider: partial.websearch?.provider ?? defaults.websearch?.provider ?? 'auto', + fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true, + webSearchPrimeUrl: + partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl, + }, }; } @@ -149,8 +156,7 @@ function generateYamlHeader(): string { # # To customize a profile: # 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json) -# 2. The file format matches Claude's settings.json: { "env": { ... } } -# +# 2. The file format matches Claude's settings.json: { "env": { ... } }\n# # Structure: # ┌─────────────────────────────────────────────────────────────────────────────┐ # │ profiles - References to *.settings.json files for API providers │ @@ -228,6 +234,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { ); lines.push(''); + // WebSearch section + if (config.websearch) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# WebSearch: MCP auto-configuration for third-party profiles'); + lines.push('# enabled: true/false - Enable/disable MCP web-search auto-config'); + lines.push('# provider: auto | web-search-prime | brave | tavily'); + lines.push('# fallback: true/false - Enable fallback chain when provider fails'); + lines.push('# API keys: Set BRAVE_API_KEY or TAVILY_API_KEY env vars'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } @@ -292,3 +315,22 @@ export function getDefaultProfile(): string | undefined { export function setDefaultProfile(name: string): void { updateUnifiedConfig({ default: name }); } + +/** + * Get websearch configuration. + * Returns defaults if not configured. + */ +export function getWebSearchConfig(): { + enabled: boolean; + provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + fallback: boolean; + webSearchPrimeUrl?: string; +} { + const config = loadOrCreateUnifiedConfig(); + return { + enabled: config.websearch?.enabled ?? true, + provider: config.websearch?.provider ?? 'auto', + fallback: config.websearch?.fallback ?? true, + webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 12e62eea..3d9fa5ce 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -100,6 +100,21 @@ export interface PreferencesConfig { auto_update?: boolean; } +/** + * WebSearch configuration. + * Controls MCP web-search auto-configuration for third-party profiles. + */ +export interface WebSearchConfig { + /** Enable auto-configuration of MCP web-search (default: true) */ + enabled?: boolean; + /** Preferred provider: auto uses fallback chain, or specify one */ + provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + /** Enable fallback chain when preferred provider fails (default: true) */ + fallback?: boolean; + /** Custom URL for web-search-prime provider (optional, overrides default) */ + webSearchPrimeUrl?: string; +} + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -117,6 +132,8 @@ export interface UnifiedConfig { cliproxy: CLIProxyConfig; /** User preferences */ preferences: PreferencesConfig; + /** WebSearch configuration */ + websearch?: WebSearchConfig; } /** @@ -154,6 +171,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { telemetry: false, auto_update: true, }, + websearch: { + enabled: true, + provider: 'auto', + fallback: true, + }, }; } diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts new file mode 100644 index 00000000..8e85cf00 --- /dev/null +++ b/src/utils/mcp-manager.ts @@ -0,0 +1,384 @@ +/** + * MCP Manager - Manages MCP server configuration for CCS + * + * Ensures web-search-prime MCP is available for third-party profiles + * that cannot use Claude's native WebSearch tool. + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * This manager auto-configures MCP web-search as a fallback. + * + * @module utils/mcp-manager + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from './ui'; +import { getWebSearchConfig } from '../config/unified-config-loader'; + +// MCP configuration file path +const MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', '.mcp.json'); + +/** + * MCP server configuration interface + */ +interface McpServerConfig { + type: 'http' | 'stdio'; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; +} + +/** + * MCP configuration file structure + */ +interface McpConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +/** + * Default web-search-prime MCP configuration (primary) + * HTTP-based, no API key needed + */ +const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = { + type: 'http', + url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', + headers: {}, +}; + +/** + * Brave Search MCP configuration (secondary fallback) + * Requires BRAVE_API_KEY env var + * Free tier: 15k queries/month, 1 query/sec + * Package: @modelcontextprotocol/server-brave-search + */ +const BRAVE_SEARCH_CONFIG: McpServerConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: {}, +}; + +/** + * Tavily MCP configuration (tertiary fallback) + * Requires TAVILY_API_KEY env var + * AI-optimized search, paid service + * Package: @tavily/mcp-server (official) + */ +const TAVILY_CONFIG: McpServerConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@tavily/mcp-server'], + env: {}, +}; + +/** + * Ensure MCP web-search is configured for third-party profiles + * + * Called before spawning Claude CLI for CLIProxy profiles. + * Respects user configuration from ~/.ccs/config.yaml: + * - enabled: false → skip auto-config + * - provider: specific → add only that provider + * - fallback: false → don't add fallback providers + * + * Multi-tier MCP fallback chain: + * 1. web-search-prime (primary, no API key needed) + * 2. brave-search (if BRAVE_API_KEY set) + * 3. tavily (if TAVILY_API_KEY set) + * + * Only adds MCPs if no web search MCP is already configured. + * + * @returns true if web search MCP is available, false on error + */ +export function ensureMcpWebSearch(): boolean { + try { + // Check user configuration + const wsConfig = getWebSearchConfig(); + + // If disabled by user, skip auto-configuration + if (!wsConfig.enabled) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch auto-config disabled by user')); + } + return false; + } + + let config: McpConfig = { mcpServers: {} }; + + // Read existing config if present + if (fs.existsSync(MCP_CONFIG_PATH)) { + const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); + try { + config = JSON.parse(content); + } catch { + // Malformed JSON - start fresh but preserve file as backup + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed .mcp.json - starting fresh')); + } + config = { mcpServers: {} }; + } + } + + // Initialize mcpServers if missing + if (!config.mcpServers) { + config.mcpServers = {}; + } + + // Check if any web search MCP already configured (case-insensitive) + const hasWebSearch = Object.keys(config.mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); + + if (hasWebSearch) { + if (process.env.CCS_DEBUG) { + console.error(info('MCP web-search already configured')); + } + return true; + } + + // Track what we add for logging + const addedMcps: string[] = []; + + // Helper to add a specific provider + const addProvider = (provider: 'web-search-prime' | 'brave' | 'tavily'): boolean => { + // mcpServers is guaranteed to exist here (initialized above) + const servers = config.mcpServers as Record; + + if (provider === 'web-search-prime') { + const webSearchPrimeConfig = { ...WEB_SEARCH_PRIME_CONFIG }; + // Use configurable URL if provided + if (wsConfig.webSearchPrimeUrl) { + webSearchPrimeConfig.url = wsConfig.webSearchPrimeUrl; + } + servers['web-search-prime'] = webSearchPrimeConfig; + addedMcps.push('web-search-prime'); + return true; + } + + if (provider === 'brave') { + const braveApiKey = process.env.BRAVE_API_KEY; + if (braveApiKey) { + const braveConfig = { ...BRAVE_SEARCH_CONFIG }; + braveConfig.env = { BRAVE_API_KEY: braveApiKey }; + servers['brave-search'] = braveConfig; + addedMcps.push('brave-search'); + return true; + } + return false; + } + + if (provider === 'tavily') { + const tavilyApiKey = process.env.TAVILY_API_KEY; + if (tavilyApiKey) { + const tavilyConfig = { ...TAVILY_CONFIG }; + tavilyConfig.env = { TAVILY_API_KEY: tavilyApiKey }; + servers['tavily'] = tavilyConfig; + addedMcps.push('tavily'); + return true; + } + return false; + } + + return false; + }; + + // Apply user's provider preference + if (wsConfig.provider === 'auto') { + // Auto mode: add all available providers + addProvider('web-search-prime'); + if (wsConfig.fallback) { + addProvider('brave'); + addProvider('tavily'); + } + } else { + // Specific provider requested + const added = addProvider(wsConfig.provider); + if (!added && wsConfig.fallback) { + // Fallback if preferred provider not available + if (process.env.CCS_DEBUG) { + console.error( + warn(`Preferred provider ${wsConfig.provider} not available, using fallback`) + ); + } + addProvider('web-search-prime'); + addProvider('brave'); + addProvider('tavily'); + } + } + + // If nothing was added, return false + if (addedMcps.length === 0) { + if (process.env.CCS_DEBUG) { + console.error(warn('No web search MCP could be configured')); + } + return false; + } + + // Ensure ~/.claude directory exists + const claudeDir = path.dirname(MCP_CONFIG_PATH); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + // Write config with proper formatting + fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + if (process.env.CCS_DEBUG) { + console.error(info(`Added MCP servers for web search: ${addedMcps.join(', ')}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to configure MCP: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Check if MCP web-search is configured + * + * @returns true if any web search MCP is present + */ +export function hasMcpWebSearch(): boolean { + try { + if (!fs.existsSync(MCP_CONFIG_PATH)) { + return false; + } + + const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); + const config: McpConfig = JSON.parse(content); + + if (!config.mcpServers) { + return false; + } + + // Case-insensitive check for web search MCP + return Object.keys(config.mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); + } catch { + return false; + } +} + +/** + * Get path to MCP config file + * + * @returns absolute path to .mcp.json + */ +export function getMcpConfigPath(): string { + return MCP_CONFIG_PATH; +} + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); +const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs'; + +/** + * Install WebSearch blocking hook to ~/.ccs/hooks/ + * + * This hook blocks native WebSearch and directs Claude to use MCP. + * Optional feature - must be explicitly enabled. + * + * @returns true if hook installed successfully + */ +export function installWebSearchHook(): boolean { + try { + // Ensure hooks directory exists + if (!fs.existsSync(CCS_HOOKS_DIR)) { + fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); + } + + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs + // In development: lib/hooks/block-websearch.cjs + const possiblePaths = [ + path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn('WebSearch hook source not found')); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch blocking hook to ${hookPath}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Get WebSearch hook configuration for settings.json + * + * @returns hook configuration object + */ +export function getWebSearchHookConfig(): Record { + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: 5, + }, + ], + }, + ], + }; +} + +/** + * Check if WebSearch blocking hook is installed + * + * @returns true if hook exists + */ +export function hasWebSearchHook(): boolean { + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + return fs.existsSync(hookPath); +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 8108563e..df30f5cf 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -53,6 +53,8 @@ import { getBackupDirectories, } from '../config/migration-manager'; import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager'; +import { getWebSearchConfig } from '../config/unified-config-loader'; +import type { WebSearchConfig } from '../config/unified-config-types'; import { isUnifiedConfig } from '../config/unified-config-types'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; @@ -1397,3 +1399,79 @@ apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): res.status(500).json({ error: (error as Error).message }); } }); + +// ==================== WebSearch Configuration ==================== + +/** + * GET /api/websearch - Get WebSearch configuration + * Returns: WebSearchConfig with enabled, provider, fallback + */ +apiRoutes.get('/websearch', (_req: Request, res: Response): void => { + try { + const config = getWebSearchConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/websearch - Update WebSearch configuration + * Body: { enabled?: boolean, provider?: string, fallback?: boolean } + */ +apiRoutes.put('/websearch', (req: Request, res: Response): void => { + const { enabled, provider, fallback, webSearchPrimeUrl } = req.body as Partial; + + // Validate enabled + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + // Validate fallback + if (fallback !== undefined && typeof fallback !== 'boolean') { + res.status(400).json({ error: 'Invalid value for fallback. Must be a boolean.' }); + return; + } + + // Validate provider if specified + const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; + if (provider && !validProviders.includes(provider)) { + res.status(400).json({ + error: `Invalid provider. Must be one of: ${validProviders.join(', ')}`, + }); + return; + } + + // Validate webSearchPrimeUrl if specified + if (webSearchPrimeUrl !== undefined && typeof webSearchPrimeUrl !== 'string') { + res.status(400).json({ error: 'Invalid value for webSearchPrimeUrl. Must be a string.' }); + return; + } + + try { + // Load existing config and update websearch section + const existingConfig = loadUnifiedConfig(); + if (!existingConfig) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + // Merge updates + existingConfig.websearch = { + enabled: enabled ?? existingConfig.websearch?.enabled ?? true, + provider: provider ?? existingConfig.websearch?.provider ?? 'auto', + fallback: fallback ?? existingConfig.websearch?.fallback ?? true, + webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl, + }; + + saveUnifiedConfig(existingConfig); + + res.json({ + success: true, + websearch: existingConfig.websearch, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/tests/unit/mcp-manager.test.ts b/tests/unit/mcp-manager.test.ts new file mode 100644 index 00000000..e82362e8 --- /dev/null +++ b/tests/unit/mcp-manager.test.ts @@ -0,0 +1,269 @@ +/** + * Unit tests for MCP Manager module + * + * Tests web search MCP configuration logic without filesystem dependencies. + * These tests focus on the pure logic functions that can be tested without + * modifying the actual config files. + */ +import { describe, it, expect } from 'bun:test'; + +/** + * Test helper: Simulate hasWebSearch detection logic + * This matches the logic in mcp-manager.ts hasMcpWebSearch() + */ +function detectWebSearchMcp(mcpServers: Record): boolean { + return Object.keys(mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); +} + +/** + * Test helper: Simulate provider configuration logic + * Returns which providers would be added given config and env + */ +function getProvidersToAdd( + wsConfig: { enabled: boolean; provider: string; fallback: boolean }, + hasExistingWebSearch: boolean, + apiKeys: { brave?: string; tavily?: string } +): string[] { + if (!wsConfig.enabled) return []; + if (hasExistingWebSearch) return []; + + const providers: string[] = []; + + if (wsConfig.provider === 'auto') { + providers.push('web-search-prime'); + if (wsConfig.fallback) { + if (apiKeys.brave) providers.push('brave-search'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'web-search-prime') { + providers.push('web-search-prime'); + } else if (wsConfig.provider === 'brave') { + if (apiKeys.brave) { + providers.push('brave-search'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'tavily') { + if (apiKeys.tavily) { + providers.push('tavily'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.brave) providers.push('brave-search'); + } + } + + return providers; +} + +describe('mcp-manager logic', () => { + describe('web search detection', () => { + it('should detect web-search-prime', () => { + const servers = { 'web-search-prime': { type: 'http' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect brave-search', () => { + const servers = { 'brave-search': { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect tavily', () => { + const servers = { tavily: { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(detectWebSearchMcp({ 'WebSearch-Custom': {} })).toBe(true); + expect(detectWebSearchMcp({ 'WEB-SEARCH': {} })).toBe(true); + expect(detectWebSearchMcp({ TAVILY: {} })).toBe(true); + expect(detectWebSearchMcp({ 'Brave-Search': {} })).toBe(true); + }); + + it('should not detect unrelated MCPs', () => { + expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false); + expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false); + expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false); + }); + + it('should return false for empty servers', () => { + expect(detectWebSearchMcp({})).toBe(false); + }); + }); + + describe('provider selection logic', () => { + const defaultConfig = { enabled: true, provider: 'auto', fallback: true }; + const noApiKeys = {}; + const braveOnly = { brave: 'test-key' }; + const tavilyOnly = { tavily: 'test-key' }; + const bothKeys = { brave: 'brave-key', tavily: 'tavily-key' }; + + it('should add web-search-prime in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, noApiKeys); + expect(providers).toContain('web-search-prime'); + }); + + it('should add brave-search when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + }); + + it('should add tavily when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, tavilyOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('tavily'); + }); + + it('should add all providers when both API keys available', () => { + const providers = getProvidersToAdd(defaultConfig, false, bothKeys); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).toContain('tavily'); + }); + + it('should not add fallbacks when fallback=false', () => { + const config = { enabled: true, provider: 'auto', fallback: false }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual(['web-search-prime']); + }); + + it('should skip when disabled', () => { + const config = { enabled: false, provider: 'auto', fallback: true }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual([]); + }); + + it('should skip when web search already exists', () => { + const providers = getProvidersToAdd(defaultConfig, true, bothKeys); + expect(providers).toEqual([]); + }); + + it('should use specific provider when configured', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toEqual(['brave-search']); + }); + + it('should fallback when specific provider not available', () => { + const config = { enabled: true, provider: 'tavily', fallback: true }; + // No tavily key, should fallback + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).not.toContain('tavily'); + }); + + it('should return empty when provider unavailable and fallback=false', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + // No brave key and no fallback + const providers = getProvidersToAdd(config, false, noApiKeys); + expect(providers).toEqual([]); + }); + }); + + describe('MCP server config structures', () => { + it('should define correct web-search-prime structure', () => { + const webSearchPrimeConfig = { + type: 'http', + url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', + headers: {}, + }; + + expect(webSearchPrimeConfig.type).toBe('http'); + expect(webSearchPrimeConfig.url).toContain('web_search_prime'); + }); + + it('should define correct brave-search structure', () => { + const braveConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: { BRAVE_API_KEY: 'test-key' }, + }; + + expect(braveConfig.type).toBe('stdio'); + expect(braveConfig.command).toBe('npx'); + expect(braveConfig.args).toContain('@modelcontextprotocol/server-brave-search'); + expect(braveConfig.env.BRAVE_API_KEY).toBe('test-key'); + }); + + it('should define correct tavily structure', () => { + const tavilyConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@tavily/mcp-server'], + env: { TAVILY_API_KEY: 'test-key' }, + }; + + expect(tavilyConfig.type).toBe('stdio'); + expect(tavilyConfig.args).toContain('@tavily/mcp-server'); + expect(tavilyConfig.env.TAVILY_API_KEY).toBe('test-key'); + }); + }); + + describe('hook configuration', () => { + it('should define correct PreToolUse hook structure', () => { + const hookConfig = { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: 'node "/path/to/hook.cjs"', + timeout: 5, + }, + ], + }, + ], + }; + + expect(hookConfig.PreToolUse).toBeDefined(); + expect(hookConfig.PreToolUse[0].matcher).toBe('WebSearch'); + expect(hookConfig.PreToolUse[0].hooks[0].type).toBe('command'); + expect(hookConfig.PreToolUse[0].hooks[0].timeout).toBe(5); + }); + }); + + describe('MCP config file path', () => { + it('should be located in .claude directory', () => { + // The MCP config path should follow this pattern + const expectedPathPattern = '.claude/.mcp.json'; + const testPath = '/home/user/.claude/.mcp.json'; + expect(testPath).toContain(expectedPathPattern); + }); + }); + + describe('WebSearch config defaults', () => { + it('should have correct default values', () => { + const defaults = { + enabled: true, + provider: 'auto', + fallback: true, + }; + + expect(defaults.enabled).toBe(true); + expect(defaults.provider).toBe('auto'); + expect(defaults.fallback).toBe(true); + }); + + it('should validate provider options', () => { + const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; + expect(validProviders).toContain('auto'); + expect(validProviders).toContain('web-search-prime'); + expect(validProviders).toContain('brave'); + expect(validProviders).toContain('tavily'); + }); + }); +}); diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 98d5c4e9..f1971496 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,42 +1,259 @@ /** - * Settings Page - Deprecated - * Settings functionality has been moved to API Profiles page + * Settings Page - WebSearch Configuration + * Configure MCP-based web search for third-party profiles */ -import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Button } from '@/components/ui/button'; -import { Construction, ArrowRight } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Globe, RefreshCw, CheckCircle2, AlertCircle, Info } from 'lucide-react'; + +interface WebSearchConfig { + enabled: boolean; + provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + fallback: boolean; +} export function SettingsPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Load config on mount + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + try { + setLoading(true); + setError(null); + const res = await fetch('/api/websearch'); + if (!res.ok) throw new Error('Failed to load WebSearch config'); + const data = await res.json(); + setConfig(data); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const saveConfig = async (updates: Partial) => { + if (!config) return; + + try { + setSaving(true); + setError(null); + setSuccess(false); + + const res = await fetch('/api/websearch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, ...updates }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setConfig(data.websearch); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+

Settings

+
+ + Loading configuration... +
+
+ ); + } + return ( -
+

Settings

- + {error && ( + + + {error} + + )} + + {success && ( + + + + Settings saved successfully + + + )} + + - - - Page Relocated + + + WebSearch Configuration + + Configure MCP-based web search for third-party profiles (gemini, agy, codex, qwen, etc.) + - -

- The settings editor has been integrated into the API Profiles page for - a better user experience. -

-

To edit environment variables for a profile:

-
    -
  1. Go to API Profiles page
  2. -
  3. Click the actions menu (...) on any profile
  4. -
  5. Select "Edit Settings"
  6. -
-
-
From fd99ebca983970b5fa1d5d366b0af5d136f9433e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 02:49:07 -0500 Subject: [PATCH 08/31] feat(websearch): add MCP fallback and Gemini CLI hook for third-party profiles - Add Gemini CLI transformer hook (websearch-gemini-transformer.cjs) - Implement MCP auto-configuration with web-search-prime, brave, tavily fallback - Add installWebSearchHook() to activate hook on profile launch - Update settings.tsx to clarify web-search-prime requires z.ai subscription - Add WebSearch config types and loader support - Create implementation plan for WebSearch UX enhancement (startup status, dashboard) Third-party profiles (gemini, agy, codex, qwen, glm, kimi) now have WebSearch capability via Gemini CLI primary + MCP fallback chain. --- docs/websearch.md | 75 ++++- lib/hooks/websearch-gemini-transformer.cjs | 324 +++++++++++++++++++++ src/ccs.ts | 3 +- src/cliproxy/cliproxy-executor.ts | 6 +- src/config/unified-config-loader.ts | 8 + src/config/unified-config-types.ts | 15 + src/utils/mcp-manager.ts | 233 +++++++++++++-- ui/src/pages/settings.tsx | 6 +- 8 files changed, 635 insertions(+), 35 deletions(-) create mode 100644 lib/hooks/websearch-gemini-transformer.cjs diff --git a/docs/websearch.md b/docs/websearch.md index c1a02392..623d1b2c 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -15,7 +15,10 @@ Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch beca - CLIProxyAPI only receives conversation messages - Tool execution never reaches the third-party backend -CCS solves this by automatically configuring MCP (Model Context Protocol) web search servers. +CCS solves this with a hybrid fallback approach: + +1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool +2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers ## Architecture @@ -28,19 +31,54 @@ CCS solves this by automatically configuring MCP (Model Context Protocol) web se │ ├── Native Claude Account? → Anthropic WebSearch API │ │ │ ($10/1000 searches) │ │ │ │ -│ └── Third-party Profile? → MCP Fallback Chain │ +│ └── Third-party Profile? → PreToolUse Hook │ │ │ │ -│ ├── 1. web-search-prime │ -│ ├── 2. Brave Search (free) │ -│ └── 3. Tavily (paid) │ +│ ├── 1. Gemini CLI │ +│ │ (google_web_search) │ +│ │ No API key needed! │ +│ │ │ +│ └── 2. MCP Fallback Chain │ +│ ├── web-search-prime │ +│ ├── Brave Search │ +│ └── Tavily │ └──────────────────────────────────────────────────────────────┘ ``` +## Gemini CLI Integration (Primary) + +The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!** + +### How It Works + +1. A PreToolUse hook intercepts WebSearch tool calls +2. Executes `gemini -p` with explicit google_web_search instruction +3. Returns search results directly to Claude via the hook's deny reason +4. Claude receives full search results and continues the conversation + +### Requirements + +- `gemini` CLI installed and authenticated (`gemini auth login`) +- OAuth authentication (no GEMINI_API_KEY needed) + +### Installation + +The Gemini CLI is typically installed via: +```bash +pip install google-generativeai +# or +pipx install google-generativeai +``` + +Then authenticate: +```bash +gemini auth login +``` + ## MCP Providers | Provider | Type | Cost | API Key Required | Notes | |----------|------|------|------------------|-------| -| web-search-prime | HTTP MCP | Free | No | Primary, always available | +| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan | | Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | | Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | @@ -65,12 +103,28 @@ websearch: provider: auto # auto | web-search-prime | brave | tavily fallback: true # Enable fallback chain (default: true) webSearchPrimeUrl: "https://..." # Optional: custom endpoint + + # Gemini CLI configuration (new!) + gemini: + enabled: true # Use Gemini CLI for WebSearch (default: true) + timeout: 55 # Timeout in seconds (default: 55) ``` +### Environment Variables + +The WebSearch hook also respects these environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` | +| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` | +| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` | +| `CCS_DEBUG` | Enable debug output | `0` | + ### Provider Options - **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available -- **web-search-prime**: Free, no API key needed +- **web-search-prime**: Requires z.ai coding plan subscription - **brave**: Requires `BRAVE_API_KEY` env var - **tavily**: Requires `TAVILY_API_KEY` env var @@ -120,6 +174,13 @@ CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: ## Troubleshooting +### Gemini CLI Issues + +1. **Not installed**: Install with `pip install google-generativeai` +2. **Not authenticated**: Run `gemini auth login` +3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` +4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only + ### WebSearch Not Working 1. **Check config**: Ensure `websearch.enabled: true` in config diff --git a/lib/hooks/websearch-gemini-transformer.cjs b/lib/hooks/websearch-gemini-transformer.cjs new file mode 100644 index 00000000..06fd43e3 --- /dev/null +++ b/lib/hooks/websearch-gemini-transformer.cjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Gemini Transformer Hook + * + * Intercepts Claude's WebSearch tool and executes search via Gemini CLI. + * This is the ultimate solution for third-party providers that lack + * native WebSearch (gemini, agy, codex, qwen profiles). + * + * Strategy: Execute-and-Deny Pattern + * 1. Intercept WebSearch PreToolUse event + * 2. Execute `gemini -p` with google_web_search tool + * 3. Return deny with search results in permissionDecisionReason + * 4. Claude receives results as feedback, continues conversation + * + * If Gemini CLI fails, falls back to MCP redirect. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "WebSearch", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/websearch-gemini-transformer.cjs", + * "timeout": 60 + * }] + * }] + * } + * } + * + * Environment variables: + * CCS_DEBUG=1 - Enable debug output + * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (allow WebSearch) + * CCS_GEMINI_SKIP=1 - Skip Gemini CLI, use MCP fallback only + * CCS_GEMINI_TIMEOUT=55 - Gemini CLI timeout in seconds (default: 55) + * + * Exit codes: + * 0 - Allow tool (pass-through) or deny with results + * 2 - Block tool (deny with message) + * + * @module hooks/websearch-gemini-transformer + */ + +const { spawnSync } = require('child_process'); + +// Minimum response length to consider valid (prevent false positives from error messages) +const MIN_VALID_RESPONSE_LENGTH = 20; + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + processHook(); +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); + +/** + * Main hook processing logic + */ +function processHook() { + try { + // Skip if disabled + if (process.env.CCS_WEBSEARCH_SKIP === '1') { + process.exit(0); + } + + const data = JSON.parse(input); + + // Only handle WebSearch tool + if (data.tool_name !== 'WebSearch') { + process.exit(0); + } + + const query = data.tool_input?.query || ''; + + if (!query) { + // No query provided - allow native handling (will fail anyway) + process.exit(0); + } + + // Check if Gemini CLI is specifically disabled - skip straight to MCP + if (process.env.CCS_GEMINI_SKIP === '1') { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Gemini CLI disabled, using MCP fallback'); + } + outputMcpFallback(query, 'Gemini CLI disabled by configuration'); + return; + } + + // Try Gemini CLI first + const geminiResult = tryGeminiSearch(query); + + if (geminiResult.success) { + // Success! Return results via deny with reason + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formatGeminiResults(query, geminiResult.content), + }, + }; + + console.log(JSON.stringify(output)); + process.exit(0); + } + + // Gemini failed - fall back to MCP redirect + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Gemini failed: ${geminiResult.error}, falling back to MCP`); + } + + outputMcpFallback(query, geminiResult.error); + } catch (err) { + // Don't block on parse errors - allow tool to proceed + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + process.exit(0); + } +} + +/** + * Try to execute search via Gemini CLI + * + * @param {string} query - Search query + * @returns {{ success: boolean, content?: string, error?: string }} + */ +function tryGeminiSearch(query) { + try { + // Get timeout from env or default to 55 seconds (under 60s hook timeout) + const timeoutSec = parseInt(process.env.CCS_GEMINI_TIMEOUT || '55', 10); + const timeoutMs = timeoutSec * 1000; + + // Build prompt for Gemini with explicit instruction to use web search + const prompt = buildGeminiPrompt(query); + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Executing: gemini -p "${prompt.substring(0, 100)}..."`); + } + + // Execute gemini CLI using spawnSync to avoid shell injection + // Note: gemini CLI uses OAuth auth, no API key needed + const spawnResult = spawnSync('gemini', ['-p', prompt], { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Handle spawn errors + if (spawnResult.error) { + throw spawnResult.error; + } + + // Check for non-zero exit code + if (spawnResult.status !== 0) { + return { + success: false, + error: `Gemini CLI exited with code ${spawnResult.status}: ${(spawnResult.stderr || '').substring(0, 200)}`, + }; + } + + const result = spawnResult.stdout || ''; + const trimmedResult = result.trim(); + + // Check if result looks like an error or empty + if (!trimmedResult || trimmedResult.length < MIN_VALID_RESPONSE_LENGTH) { + return { + success: false, + error: 'Empty or too short response from Gemini', + }; + } + + // Check for common error patterns + const lowerResult = trimmedResult.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('unable to') || + lowerResult.includes('authentication required') + ) { + return { + success: false, + error: `Gemini returned error: ${trimmedResult.substring(0, 100)}`, + }; + } + + return { + success: true, + content: trimmedResult, + }; + } catch (err) { + // Handle different error types + if (err.killed) { + return { + success: false, + error: 'Gemini CLI timed out', + }; + } + + if (err.code === 'ENOENT') { + return { + success: false, + error: 'Gemini CLI not found (not installed or not in PATH)', + }; + } + + // Check stderr for more details + if (err.stderr) { + return { + success: false, + error: `Gemini CLI error: ${err.stderr.substring(0, 200)}`, + }; + } + + return { + success: false, + error: err.message || 'Unknown error executing Gemini CLI', + }; + } +} + +/** + * Build the prompt for Gemini CLI + * Instructs Gemini to use google_web_search tool for the query + * + * @param {string} query - Original search query + * @returns {string} Formatted prompt + */ +function buildGeminiPrompt(query) { + return [ + `Search the web for: ${query}`, + '', + 'Instructions:', + '1. Use the google_web_search tool to find current information', + '2. Provide a comprehensive summary of the search results', + '3. Include relevant URLs/sources when available', + '4. Be concise but thorough', + '5. Focus on factual, up-to-date information', + ].join('\n'); +} + +/** + * Format Gemini search results for Claude + * + * @param {string} query - Original query + * @param {string} content - Gemini response content + * @returns {string} Formatted message for Claude + */ +function formatGeminiResults(query, content) { + return [ + '[WebSearch Results via Gemini]', + '', + `Query: "${query}"`, + '', + '---', + '', + content, + '', + '---', + '', + 'Search completed successfully. You can use this information to answer the user\'s question.', + 'If you need more specific information, you can search again with a refined query.', + ].join('\n'); +} + +/** + * Get MCP fallback message when Gemini fails + * + * @param {string} query - Original query + * @param {string} error - Error message from Gemini attempt + * @returns {string} Message directing Claude to use MCP + */ +function getMcpFallbackMessage(query, error) { + return [ + '[WebSearch Unavailable - Use MCP Fallback]', + '', + `Gemini CLI failed: ${error || 'Unknown error'}`, + '', + 'The native WebSearch tool is not available with your current provider.', + 'Please use one of the following MCP tools instead:', + '', + '1. mcp__web-search-prime__webSearchPrime (requires z.ai subscription)', + '2. mcp__brave-search__brave_web_search (if configured)', + '3. mcp__tavily__search (if configured)', + '', + `Use the same query: "${query}"`, + '', + 'Example:', + '```', + 'mcp__web-search-prime__webSearchPrime', + `search_query: "${query}"`, + '```', + ].join('\n'); +} + +/** + * Output MCP fallback response and exit + * + * @param {string} query - Original query + * @param {string} error - Error message + */ +function outputMcpFallback(query, error) { + const output = { + decision: 'block', + reason: 'WebSearch unavailable - use MCP fallback', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: getMcpFallbackMessage(query, error), + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} diff --git a/src/ccs.ts b/src/ccs.ts index 0ad69fe5..af79374a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,7 +14,7 @@ import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; -import { ensureMcpWebSearch } from './utils/mcp-manager'; +import { ensureMcpWebSearch, installWebSearchHook } from './utils/mcp-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -382,6 +382,7 @@ async function main(): Promise { // Settings-based profiles (glm, glmt, kimi) are third-party providers // WebSearch is server-side tool - third-party providers have no access ensureMcpWebSearch(); + installWebSearchHook(); // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a7ae91ac..d81a1d19 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -39,7 +39,7 @@ import { } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; -import { ensureMcpWebSearch } from '../utils/mcp-manager'; +import { ensureMcpWebSearch, installWebSearchHook } from '../utils/mcp-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -118,6 +118,10 @@ export async function execClaudeWithCLIProxy( // Third-party providers don't have access, so we use MCP fallback ensureMcpWebSearch(); + // Install WebSearch hook for Gemini CLI + MCP fallback + // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP + installWebSearchHook(); + // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 2b181ba0..909e81aa 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -325,6 +325,10 @@ export function getWebSearchConfig(): { provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; fallback: boolean; webSearchPrimeUrl?: string; + gemini: { + enabled: boolean; + timeout: number; + }; } { const config = loadOrCreateUnifiedConfig(); return { @@ -332,5 +336,9 @@ export function getWebSearchConfig(): { provider: config.websearch?.provider ?? 'auto', fallback: config.websearch?.fallback ?? true, webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl, + gemini: { + enabled: config.websearch?.gemini?.enabled ?? true, + timeout: config.websearch?.gemini?.timeout ?? 55, + }, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3d9fa5ce..08949c6d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -113,6 +113,17 @@ export interface WebSearchConfig { fallback?: boolean; /** Custom URL for web-search-prime provider (optional, overrides default) */ webSearchPrimeUrl?: string; + /** + * Gemini CLI configuration for WebSearch transformation. + * Uses `gemini -p` with google_web_search tool as primary method. + * No API key needed - uses OAuth authentication. + */ + gemini?: { + /** Enable Gemini CLI for WebSearch (default: true) */ + enabled?: boolean; + /** Timeout in seconds for Gemini CLI (default: 55) */ + timeout?: number; + }; } /** @@ -175,6 +186,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: true, provider: 'auto', fallback: true, + gemini: { + enabled: true, + timeout: 55, + }, }, }; } diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts index 8e85cf00..855d1686 100644 --- a/src/utils/mcp-manager.ts +++ b/src/utils/mcp-manager.ts @@ -42,7 +42,7 @@ interface McpConfig { /** * Default web-search-prime MCP configuration (primary) - * HTTP-based, no API key needed + * HTTP-based, requires z.ai coding plan subscription */ const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = { type: 'http', @@ -86,7 +86,7 @@ const TAVILY_CONFIG: McpServerConfig = { * - fallback: false → don't add fallback providers * * Multi-tier MCP fallback chain: - * 1. web-search-prime (primary, no API key needed) + * 1. web-search-prime (primary, requires z.ai subscription) * 2. brave-search (if BRAVE_API_KEY set) * 3. tavily (if TAVILY_API_KEY set) * @@ -290,31 +290,45 @@ export function getMcpConfigPath(): string { // CCS hooks directory const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); +// Legacy hook for simple MCP redirect (deprecated) const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs'; +// New hybrid hook: Gemini CLI first, MCP fallback +const GEMINI_TRANSFORMER_HOOK = 'websearch-gemini-transformer.cjs'; +// Default hook timeout in seconds (Gemini CLI needs time) +const HOOK_TIMEOUT_SECONDS = 60; +// Path to Claude settings.json +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); /** - * Install WebSearch blocking hook to ~/.ccs/hooks/ + * Install WebSearch transformer hook to ~/.ccs/hooks/ * - * This hook blocks native WebSearch and directs Claude to use MCP. - * Optional feature - must be explicitly enabled. + * This hook intercepts WebSearch and: + * 1. Tries Gemini CLI with google_web_search (no API key needed) + * 2. Falls back to MCP redirect if Gemini fails * + * This is the ultimate solution for third-party profiles. + * + * @param options - Installation options + * @param options.legacy - Install legacy block-websearch.cjs instead (default: false) * @returns true if hook installed successfully */ -export function installWebSearchHook(): boolean { +export function installWebSearchHook(options?: { legacy?: boolean }): boolean { try { // Ensure hooks directory exists if (!fs.existsSync(CCS_HOOKS_DIR)) { fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); } - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + // Determine which hook to install + const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK; + const hookPath = path.join(CCS_HOOKS_DIR, hookFileName); // Find the bundled hook script - // In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs - // In development: lib/hooks/block-websearch.cjs + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ const possiblePaths = [ - path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), - path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName), + path.join(__dirname, '..', 'lib', 'hooks', hookFileName), ]; let sourcePath: string | null = null; @@ -327,7 +341,7 @@ export function installWebSearchHook(): boolean { if (!sourcePath) { if (process.env.CCS_DEBUG) { - console.error(warn('WebSearch hook source not found')); + console.error(warn(`WebSearch hook source not found: ${hookFileName}`)); } return false; } @@ -336,10 +350,31 @@ export function installWebSearchHook(): boolean { fs.copyFileSync(sourcePath, hookPath); fs.chmodSync(hookPath, 0o755); - if (process.env.CCS_DEBUG) { - console.error(info(`Installed WebSearch blocking hook to ${hookPath}`)); + // Also install the legacy hook for backward compatibility + if (!options?.legacy) { + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + const legacySourcePaths = [ + path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + ]; + + for (const p of legacySourcePaths) { + if (fs.existsSync(p)) { + fs.copyFileSync(p, legacyHookPath); + fs.chmodSync(legacyHookPath, 0o755); + break; + } + } } + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch hook: ${hookPath}`)); + } + + // Also ensure the hook is configured in settings.json + // This is called after the hook file is installed + ensureWebSearchHookConfigInternal(); + return true; } catch (error) { if (process.env.CCS_DEBUG) { @@ -352,10 +387,17 @@ export function installWebSearchHook(): boolean { /** * Get WebSearch hook configuration for settings.json * - * @returns hook configuration object + * Returns config for the Gemini transformer hook (default) or legacy hook. + * + * @param options - Configuration options + * @param options.legacy - Use legacy block-websearch.cjs instead (default: false) + * @returns hook configuration object for settings.json */ -export function getWebSearchHookConfig(): Record { - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); +export function getWebSearchHookConfig(options?: { legacy?: boolean }): Record { + const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK; + const hookPath = path.join(CCS_HOOKS_DIR, hookFileName); + // Legacy hook only needs 5s, Gemini hook needs 60s for CLI execution + const timeout = options?.legacy ? 5 : HOOK_TIMEOUT_SECONDS; return { PreToolUse: [ @@ -365,7 +407,7 @@ export function getWebSearchHookConfig(): Record { { type: 'command', command: `node "${hookPath}"`, - timeout: 5, + timeout: timeout, }, ], }, @@ -374,11 +416,158 @@ export function getWebSearchHookConfig(): Record { } /** - * Check if WebSearch blocking hook is installed + * Check if WebSearch hook is installed * - * @returns true if hook exists + * Checks for both the new Gemini transformer and legacy block hook. + * + * @returns true if any WebSearch hook exists */ export function hasWebSearchHook(): boolean { - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); - return fs.existsSync(hookPath); + const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK); + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + return fs.existsSync(geminiHookPath) || fs.existsSync(legacyHookPath); +} + +/** + * Get information about installed WebSearch hooks + * + * @returns Object with hook status details + */ +export function getWebSearchHookStatus(): { + installed: boolean; + geminiTransformer: boolean; + legacyBlock: boolean; + activePath: string | null; +} { + const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK); + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + const hasGemini = fs.existsSync(geminiHookPath); + const hasLegacy = fs.existsSync(legacyHookPath); + + return { + installed: hasGemini || hasLegacy, + geminiTransformer: hasGemini, + legacyBlock: hasLegacy, + // Gemini transformer takes priority + activePath: hasGemini ? geminiHookPath : hasLegacy ? legacyHookPath : null, + }; +} + +/** + * Get environment variables for WebSearch hook configuration. + * + * These env vars are read by the websearch-gemini-transformer.cjs hook + * to control its behavior without requiring file I/O. + * + * @returns Record of environment variables to set before spawning Claude + */ +export function getWebSearchHookEnv(): Record { + const wsConfig = getWebSearchConfig(); + const env: Record = {}; + + // Skip hook entirely if disabled + if (!wsConfig.enabled) { + env.CCS_WEBSEARCH_SKIP = '1'; + return env; + } + + // Skip Gemini CLI if specifically disabled + if (!wsConfig.gemini.enabled) { + env.CCS_GEMINI_SKIP = '1'; + } + + // Set Gemini timeout + if (wsConfig.gemini.timeout) { + env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout); + } + + return env; +} + +/** + * Ensure WebSearch hook is configured in ~/.claude/settings.json + * + * Merges the hook configuration into existing settings.json without + * overwriting other settings. Only adds if not already present. + * + * This is an internal function called by installWebSearchHook. + * + * @returns true if hook config is present (already existed or was added) + */ +function ensureWebSearchHookConfigInternal(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + // Skip if disabled + if (!wsConfig.enabled) { + return false; + } + + // Read existing settings or start fresh + let settings: Record = {}; + if (fs.existsSync(CLAUDE_SETTINGS_PATH)) { + try { + const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'); + settings = JSON.parse(content); + } catch { + // Malformed JSON - start fresh + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed settings.json - will merge carefully')); + } + } + } + + // Check if WebSearch hook already configured + const hooks = settings.hooks as Record | undefined; + if (hooks?.PreToolUse) { + const hasWebSearchHook = hooks.PreToolUse.some((h: unknown) => { + const hook = h as Record; + return hook.matcher === 'WebSearch'; + }); + + if (hasWebSearchHook) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch hook already configured in settings.json')); + } + return true; + } + } + + // Get hook config + const hookConfig = getWebSearchHookConfig(); + + // Merge hook config into settings + if (!settings.hooks) { + settings.hooks = {}; + } + + const settingsHooks = settings.hooks as Record; + if (!settingsHooks.PreToolUse) { + settingsHooks.PreToolUse = []; + } + + // Add our hook config + const preToolUseHooks = hookConfig.PreToolUse as unknown[]; + settingsHooks.PreToolUse.push(...preToolUseHooks); + + // Ensure ~/.claude directory exists + const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + // Write updated settings + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + + if (process.env.CCS_DEBUG) { + console.error(info('Added WebSearch hook to settings.json')); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`)); + } + return false; + } } diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index f1971496..92a36d16 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -163,9 +163,7 @@ export function SettingsPage() { -

- Primary web search provider to use -

+

Primary web search provider to use