feat: agent variants — migration, API, service integration, frontend, tests
- Migration 027: agent_variants table with single-active enforcement, variant_id column on agent_performance_log - API: full CRUD, clone from agent/variant, activate/deactivate, per-variant performance metrics and history endpoints - Services: extractor, event classifier, thesis rewriter all wired to AgentConfigResolver with variant override support - Frontend: variant list, comparison view, create/edit/clone forms, activate/delete actions on Agents page - Tests: API tests + 5 property-based tests (single-active invariant, clone preservation, config resolution, slug determinism, update idempotence) - Spec files for agent-variants feature
This commit is contained in:
Generated
+115
-115
@@ -369,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -393,9 +393,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -410,7 +410,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.1.1"
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -444,9 +444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
|
||||
"integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -921,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1566,14 +1566,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-router": {
|
||||
"version": "1.168.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.18.tgz",
|
||||
"integrity": "sha512-RmBptS3/qtkGhvG/u41JWOgxz1FIWybBz7iBTgLUIoFkqOj6NE4XlhUOsP2fabxACtbZdJnpvCWcJFWpWGIngw==",
|
||||
"version": "1.168.22",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.22.tgz",
|
||||
"integrity": "sha512-W2LyfkfJtDCf//jOjZeUBWwOVl8iDRVTECpGHa2M28MT3T5/VVnjgicYNHR/ax0Filk1iU67MRjcjHheTYvK1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.161.6",
|
||||
"@tanstack/react-store": "^0.9.3",
|
||||
"@tanstack/router-core": "1.168.14",
|
||||
"@tanstack/router-core": "1.168.15",
|
||||
"isbot": "^5.1.22"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1607,9 +1607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.168.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.14.tgz",
|
||||
"integrity": "sha512-UhCJtjNrd5wcTmhgB2HyUP0+Rj1M7BD4dS11YsF9x6VC2KH/eqxzs/vK+nN5f+cOhPOLZdmLkWMW+WGmacZ8HA==",
|
||||
"version": "1.168.15",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.15.tgz",
|
||||
"integrity": "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.161.6",
|
||||
@@ -1893,17 +1893,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
|
||||
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/type-utils": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/type-utils": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -1916,7 +1916,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -1932,16 +1932,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
|
||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1957,14 +1957,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
|
||||
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
|
||||
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.1",
|
||||
"@typescript-eslint/types": "^8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.2",
|
||||
"@typescript-eslint/types": "^8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1979,14 +1979,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
|
||||
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
|
||||
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1"
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1997,9 +1997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2014,15 +2014,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -2039,9 +2039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
|
||||
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
|
||||
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2053,16 +2053,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
|
||||
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
|
||||
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/project-service": "8.58.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2133,16 +2133,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
|
||||
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
|
||||
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1"
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2157,13 +2157,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
|
||||
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
|
||||
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2427,9 +2427,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
|
||||
"integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==",
|
||||
"version": "2.10.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
|
||||
"integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2505,9 +2505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001787",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
|
||||
"version": "1.0.30001788",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
|
||||
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2912,9 +2912,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.335",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz",
|
||||
"integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==",
|
||||
"version": "1.5.336",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
|
||||
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3338,9 +3338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
|
||||
"integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
|
||||
"version": "17.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
|
||||
"integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3527,9 +3527,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbot": {
|
||||
"version": "5.1.37",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.37.tgz",
|
||||
"integrity": "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==",
|
||||
"version": "5.1.38",
|
||||
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.38.tgz",
|
||||
"integrity": "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3613,9 +3613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -4066,9 +4066,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.13.2",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz",
|
||||
"integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==",
|
||||
"version": "2.13.3",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz",
|
||||
"integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -4084,7 +4084,7 @@
|
||||
"outvariant": "^1.4.3",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"rettime": "^0.10.1",
|
||||
"rettime": "^0.11.7",
|
||||
"statuses": "^2.0.2",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"tough-cookie": "^6.0.0",
|
||||
@@ -4299,9 +4299,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4531,9 +4531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rettime": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
|
||||
"integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==",
|
||||
"version": "0.11.7",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz",
|
||||
"integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4702,9 +4702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4980,16 +4980,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
|
||||
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
|
||||
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5004,9 +5004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
|
||||
import { apiGet, apiPost, apiPut, apiDelete, ApiError } from '../api/client';
|
||||
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,6 +52,29 @@ interface PerfHistoryPoint {
|
||||
avg_confidence: number;
|
||||
}
|
||||
|
||||
interface AgentVariant {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
variant_name: string;
|
||||
variant_slug: string;
|
||||
description: string;
|
||||
model_provider: string;
|
||||
model_name: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
prompt_version: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
context_window: number;
|
||||
input_token_limit: number;
|
||||
token_budget: number;
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,6 +102,82 @@ function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Variant query hooks --
|
||||
|
||||
function useAgentVariants(agentId: string | undefined) {
|
||||
return useQuery<AgentVariant[]>({
|
||||
queryKey: ['agent-variants', agentId],
|
||||
queryFn: () => apiGet<AgentVariant[]>('query', `/api/agents/${agentId}/variants`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVariantPerformance(agentId: string, variantId: string, hours = 24) {
|
||||
return useQuery<AgentPerformance>({
|
||||
queryKey: ['variant-performance', agentId, variantId, hours],
|
||||
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/variants/${variantId}/performance?hours=${hours}`),
|
||||
enabled: !!agentId && !!variantId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVariantPerfHistory(agentId: string, variantId: string, hours = 24) {
|
||||
return useQuery<PerfHistoryPoint[]>({
|
||||
queryKey: ['variant-perf-history', agentId, variantId, hours],
|
||||
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/variants/${variantId}/performance/history?hours=${hours}`),
|
||||
enabled: !!agentId && !!variantId,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Variant mutation hooks --
|
||||
|
||||
function useCloneAgentAsVariant(agentId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/clone`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVariant(agentId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateVariant(agentId: string, variantId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPut<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
function useDeleteVariant(agentId: string, variantId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agentId}/variants/${variantId}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
function useActivateVariant(agentId: string, variantId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}/activate`, {}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeactivateVariants(agentId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiPost<unknown>('query', `/api/agents/${agentId}/variants/deactivate`, {}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,6 +261,36 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
|
||||
const qc = useQueryClient();
|
||||
const { data: perf } = useAgentPerformance(agent.id);
|
||||
const { data: history } = useAgentPerfHistory(agent.id);
|
||||
const { data: variants } = useAgentVariants(agent.id);
|
||||
|
||||
// Variant UI state
|
||||
const [variantView, setVariantView] = useState<
|
||||
| { mode: 'list' }
|
||||
| { mode: 'clone-agent' }
|
||||
| { mode: 'clone-variant'; source: AgentVariant }
|
||||
| { mode: 'edit-variant'; variant: AgentVariant }
|
||||
| { mode: 'delete-confirm'; variant: AgentVariant }
|
||||
>({ mode: 'list' });
|
||||
|
||||
// Comparison selection state
|
||||
const [selectedVariantIds, setSelectedVariantIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Clear selection when variants change (e.g., after activation, deletion)
|
||||
useEffect(() => {
|
||||
setSelectedVariantIds(new Set());
|
||||
}, [variants]);
|
||||
|
||||
const toggleVariantSelection = useCallback((variantId: string) => {
|
||||
setSelectedVariantIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(variantId)) {
|
||||
next.delete(variantId);
|
||||
} else {
|
||||
next.add(variantId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
|
||||
@@ -188,6 +317,7 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setVariantView({ mode: 'clone-agent' })} className="rounded-md border border-brand-500/50 px-3 py-1.5 text-sm font-medium text-brand-300 hover:bg-brand-600/20">Clone as Variant</button>
|
||||
<button onClick={onEdit} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">Edit</button>
|
||||
{agent.source === 'user' && (
|
||||
<button onClick={() => deleteMut.mutate()} className="rounded-md border border-red-700/50 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/20">Delete</button>
|
||||
@@ -258,10 +388,601 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Variant Section */}
|
||||
{variantView.mode === 'clone-agent' && (
|
||||
<VariantCloneForm
|
||||
agentId={agent.id}
|
||||
source={{
|
||||
model_provider: agent.model_provider,
|
||||
model_name: agent.model_name,
|
||||
system_prompt: agent.system_prompt,
|
||||
user_prompt_template: agent.user_prompt_template,
|
||||
prompt_version: agent.prompt_version,
|
||||
temperature: agent.temperature,
|
||||
max_tokens: agent.max_tokens,
|
||||
context_window: 0,
|
||||
input_token_limit: 0,
|
||||
token_budget: 0,
|
||||
timeout_seconds: agent.timeout_seconds,
|
||||
max_retries: agent.max_retries,
|
||||
}}
|
||||
sourceType="agent"
|
||||
onDone={() => setVariantView({ mode: 'list' })}
|
||||
/>
|
||||
)}
|
||||
{variantView.mode === 'clone-variant' && (
|
||||
<VariantCloneForm
|
||||
agentId={agent.id}
|
||||
source={variantView.source}
|
||||
sourceType="variant"
|
||||
sourceVariantId={variantView.source.id}
|
||||
onDone={() => setVariantView({ mode: 'list' })}
|
||||
/>
|
||||
)}
|
||||
{variantView.mode === 'edit-variant' && (
|
||||
<VariantEditForm
|
||||
agentId={agent.id}
|
||||
variant={variantView.variant}
|
||||
onDone={() => setVariantView({ mode: 'list' })}
|
||||
/>
|
||||
)}
|
||||
{variantView.mode === 'delete-confirm' && (
|
||||
<DeleteVariantDialog
|
||||
agentId={agent.id}
|
||||
variant={variantView.variant}
|
||||
onDone={() => setVariantView({ mode: 'list' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VariantList
|
||||
agentId={agent.id}
|
||||
variants={variants ?? []}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelection}
|
||||
onClone={(v) => setVariantView({ mode: 'clone-variant', source: v })}
|
||||
onEdit={(v) => setVariantView({ mode: 'edit-variant', variant: v })}
|
||||
onDelete={(v) => setVariantView({ mode: 'delete-confirm', variant: v })}
|
||||
/>
|
||||
|
||||
{selectedVariantIds.size >= 2 && (
|
||||
<VariantCompare
|
||||
agentId={agent.id}
|
||||
variants={(variants ?? []).filter((v) => selectedVariantIds.has(v.id))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VariantList({ agentId, variants, selectedIds, onToggleSelect, onClone, onEdit, onDelete }: {
|
||||
agentId: string;
|
||||
variants: AgentVariant[];
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onClone: (v: AgentVariant) => void;
|
||||
onEdit: (v: AgentVariant) => void;
|
||||
onDelete: (v: AgentVariant) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Variants ({variants.length})</h2>
|
||||
{variants.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No variants yet. Clone this agent to create one.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-xs text-gray-500">
|
||||
<th className="pb-2 pr-2 w-8">
|
||||
<span className="sr-only">Select</span>
|
||||
</th>
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Model</th>
|
||||
<th className="pb-2 pr-4">Status</th>
|
||||
<th className="pb-2 pr-4">Created</th>
|
||||
<th className="pb-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{variants.map((v) => (
|
||||
<VariantRow
|
||||
key={v.id}
|
||||
agentId={agentId}
|
||||
variant={v}
|
||||
selected={selectedIds.has(v.id)}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onClone={onClone}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{selectedIds.size > 0 && selectedIds.size < 2 && (
|
||||
<p className="mt-2 text-[10px] text-gray-500">Select at least 2 variants to compare.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantRow({ agentId, variant, selected, onToggleSelect, onClone, onEdit, onDelete }: {
|
||||
agentId: string;
|
||||
variant: AgentVariant;
|
||||
selected: boolean;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onClone: (v: AgentVariant) => void;
|
||||
onEdit: (v: AgentVariant) => void;
|
||||
onDelete: (v: AgentVariant) => void;
|
||||
}) {
|
||||
const activateMut = useActivateVariant(agentId, variant.id);
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-surface-800 ${variant.is_active ? 'bg-green-900/10' : ''}`}>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect(variant.id)}
|
||||
className="rounded border-surface-600 bg-surface-950 text-brand-500 focus:ring-brand-500 focus:ring-offset-0 h-3.5 w-3.5"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-medium text-gray-200">{variant.variant_name}</span>
|
||||
{variant.description && <p className="text-[10px] text-gray-500 truncate max-w-[200px]">{variant.description}</p>}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-gray-400">{variant.model_name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{variant.is_active ? <StatusBadge status="active" /> : <span className="text-xs text-gray-600">inactive</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-gray-500">{new Date(variant.created_at).toLocaleDateString()}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button onClick={() => onEdit(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Edit</button>
|
||||
<button onClick={() => onClone(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Clone</button>
|
||||
<button onClick={() => onDelete(variant)} className="rounded px-2 py-0.5 text-[10px] text-red-400 hover:bg-red-900/20">Delete</button>
|
||||
<button
|
||||
onClick={() => activateMut.mutate()}
|
||||
disabled={variant.is_active || activateMut.isPending}
|
||||
className="rounded px-2 py-0.5 text-[10px] font-medium text-brand-400 hover:bg-brand-600/20 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{activateMut.isPending ? '…' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant Comparison View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COMPARE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||
|
||||
function VariantCompareColumn({ agentId, variant, color }: {
|
||||
agentId: string;
|
||||
variant: AgentVariant;
|
||||
color: string;
|
||||
}) {
|
||||
const { data: perf } = useVariantPerformance(agentId, variant.id);
|
||||
const activateMut = useActivateVariant(agentId, variant.id);
|
||||
|
||||
return (
|
||||
<td className="px-3 py-2 text-center text-sm">
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-200">{variant.variant_name}</span>
|
||||
<span className="ml-2 text-[10px] font-mono text-gray-500">{variant.model_name}</span>
|
||||
{variant.is_active && <StatusBadge status="active" />}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500">Success Rate</span>
|
||||
<div className={`font-bold ${perf?.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
{perf?.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Latency</span>
|
||||
<div className="text-gray-200">{perf?.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">P95 Latency</span>
|
||||
<div className="text-gray-200">{perf?.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Confidence</span>
|
||||
<div className="text-gray-200">{perf?.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Total Tokens</span>
|
||||
<div className="text-gray-200">
|
||||
{perf?.total_input_tokens != null || perf?.total_output_tokens != null
|
||||
? ((perf.total_input_tokens ?? 0) + (perf.total_output_tokens ?? 0)).toLocaleString()
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!variant.is_active && (
|
||||
<button
|
||||
onClick={() => activateMut.mutate()}
|
||||
disabled={activateMut.isPending}
|
||||
className="mt-3 rounded-md px-3 py-1 text-[10px] font-medium text-white hover:opacity-90 disabled:opacity-30"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{activateMut.isPending ? 'Activating…' : 'Activate'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantCompareChart({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
|
||||
// Fetch history for each selected variant
|
||||
const historyQueries = variants.map((v) =>
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useVariantPerfHistory(agentId, v.id)
|
||||
);
|
||||
|
||||
// Merge all history data into a unified time-series keyed by hour
|
||||
const hourMap = new Map<string, Record<string, number | string>>();
|
||||
|
||||
variants.forEach((v, idx) => {
|
||||
const history = historyQueries[idx].data ?? [];
|
||||
for (const pt of history) {
|
||||
const hourKey = new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (!hourMap.has(hourKey)) {
|
||||
hourMap.set(hourKey, { hour: hourKey });
|
||||
}
|
||||
const entry = hourMap.get(hourKey)!;
|
||||
entry[`sr_${v.id}`] = pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0;
|
||||
entry[`lat_${v.id}`] = Math.round(pt.avg_duration_ms);
|
||||
}
|
||||
});
|
||||
|
||||
const chartData = Array.from(hourMap.values());
|
||||
|
||||
if (chartData.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-500">Success Rate Over Time</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend />
|
||||
{variants.map((v, idx) => (
|
||||
<Line
|
||||
key={v.id}
|
||||
type="monotone"
|
||||
dataKey={`sr_${v.id}`}
|
||||
name={v.variant_name}
|
||||
stroke={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantCompare({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Variant Comparison ({variants.length} selected)</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700">
|
||||
<th className="pb-2 pr-4 text-left text-xs text-gray-500">Metric</th>
|
||||
{variants.map((v, idx) => (
|
||||
<th key={v.id} className="pb-2 px-3 text-center text-xs" style={{ color: COMPARE_COLORS[idx % COMPARE_COLORS.length] }}>
|
||||
{v.variant_name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 pr-4 text-xs text-gray-500">Metrics</td>
|
||||
{variants.map((v, idx) => (
|
||||
<VariantCompareColumn
|
||||
key={v.id}
|
||||
agentId={agentId}
|
||||
variant={v}
|
||||
color={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<VariantCompareChart agentId={agentId} variants={variants} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant Clone Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface VariantConfigSource {
|
||||
model_provider: string;
|
||||
model_name: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
prompt_version: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
context_window: number;
|
||||
input_token_limit: number;
|
||||
token_budget: number;
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
}
|
||||
|
||||
function VariantCloneForm({ agentId, source, sourceType, sourceVariantId, onDone }: {
|
||||
agentId: string;
|
||||
source: VariantConfigSource;
|
||||
sourceType: 'agent' | 'variant';
|
||||
sourceVariantId?: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
variant_name: '',
|
||||
description: '',
|
||||
model_provider: source.model_provider,
|
||||
model_name: source.model_name,
|
||||
system_prompt: source.system_prompt,
|
||||
user_prompt_template: source.user_prompt_template,
|
||||
prompt_version: source.prompt_version,
|
||||
temperature: source.temperature,
|
||||
max_tokens: source.max_tokens,
|
||||
context_window: source.context_window,
|
||||
input_token_limit: source.input_token_limit,
|
||||
token_budget: source.token_budget,
|
||||
timeout_seconds: source.timeout_seconds,
|
||||
max_retries: source.max_retries,
|
||||
});
|
||||
|
||||
const cloneAgentMut = useCloneAgentAsVariant(agentId);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// For cloning from a variant, we use a direct mutation since the hook needs variantId
|
||||
const cloneVariantMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${sourceVariantId}/clone`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
|
||||
});
|
||||
|
||||
const mutation = sourceType === 'agent' ? cloneAgentMut : cloneVariantMut;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form, { onSuccess: () => onDone() });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">
|
||||
Clone {sourceType === 'agent' ? 'Agent' : 'Variant'} as New Variant
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Variant Name">
|
||||
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
<FormRow label="Description">
|
||||
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<FormRow label="User Prompt Template">
|
||||
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-20 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormRow label="Context Window (0 = default)">
|
||||
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Input Token Limit (0 = unlimited)">
|
||||
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Token Budget/hr (0 = unlimited)">
|
||||
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="Prompt Version">
|
||||
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Cloning…' : 'Clone'}
|
||||
</button>
|
||||
<button type="button" onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to clone: {(mutation.error as ApiError)?.status === 409 ? 'Slug already exists' : 'Unknown error'}</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant Edit Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VariantEditForm({ agentId, variant, onDone }: {
|
||||
agentId: string;
|
||||
variant: AgentVariant;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
variant_name: variant.variant_name,
|
||||
description: variant.description,
|
||||
model_provider: variant.model_provider,
|
||||
model_name: variant.model_name,
|
||||
system_prompt: variant.system_prompt,
|
||||
user_prompt_template: variant.user_prompt_template,
|
||||
prompt_version: variant.prompt_version,
|
||||
temperature: variant.temperature,
|
||||
max_tokens: variant.max_tokens,
|
||||
context_window: variant.context_window,
|
||||
input_token_limit: variant.input_token_limit,
|
||||
token_budget: variant.token_budget,
|
||||
timeout_seconds: variant.timeout_seconds,
|
||||
max_retries: variant.max_retries,
|
||||
});
|
||||
|
||||
const mutation = useUpdateVariant(agentId, variant.id);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form, { onSuccess: () => onDone() });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Variant: {variant.variant_name}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Variant Name">
|
||||
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
<FormRow label="Description">
|
||||
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<FormRow label="User Prompt Template">
|
||||
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-20 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormRow label="Context Window (0 = default)">
|
||||
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Input Token Limit (0 = unlimited)">
|
||||
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Token Budget/hr (0 = unlimited)">
|
||||
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="Prompt Version">
|
||||
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to save variant</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete Variant Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteVariantDialog({ agentId, variant, onDone }: {
|
||||
agentId: string;
|
||||
variant: AgentVariant;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const mutation = useDeleteVariant(agentId, variant.id);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleDelete() {
|
||||
setError(null);
|
||||
mutation.mutate(undefined, {
|
||||
onSuccess: () => onDone(),
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setError('Cannot delete the active variant. Deactivate it first or activate a different variant.');
|
||||
} else {
|
||||
setError('Failed to delete variant.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-red-400">Delete Variant</h2>
|
||||
<p className="text-sm text-gray-300 mb-1">
|
||||
Are you sure you want to delete <span className="font-medium text-gray-100">{variant.variant_name}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-4">This action cannot be undone. Associated performance log entries will be unlinked.</p>
|
||||
{error && <p className="text-xs text-red-400 mb-3">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleDelete} disabled={mutation.isPending} className="rounded-md bg-red-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
<button onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -54,6 +54,25 @@ export const mockCorporateDecisions = [
|
||||
{ catalyst_type: 'm_and_a', date: '2026-03-15T00:00:00Z', summary: 'Acquisition of AI startup for $2B', trend_direction: 'bullish', trend_strength: 0.7, sample_count: 5, pattern_confidence: 0.68, document_id: 'd1' },
|
||||
];
|
||||
|
||||
export const mockAgents = [
|
||||
{ id: 'agent-1', name: 'Document Extractor', slug: 'document-extractor', purpose: 'Extract structured data from documents', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.0, max_tokens: 32768, timeout_seconds: 120, max_retries: 2, active: true, source: 'system', created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z' },
|
||||
{ id: 'agent-2', name: 'Event Classifier', slug: 'event-classifier', purpose: 'Classify global events', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You classify events.', user_prompt_template: 'Classify: {event}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.1, max_tokens: 16384, timeout_seconds: 60, max_retries: 3, active: true, source: 'system', created_at: '2026-04-02T00:00:00Z', updated_at: '2026-04-02T00:00:00Z' },
|
||||
];
|
||||
|
||||
export const mockAgentVariants = [
|
||||
{ id: 'var-1', agent_id: 'agent-1', variant_name: 'GPT-4o Variant', variant_slug: 'gpt-4o-variant', description: 'Uses GPT-4o for extraction', model_provider: 'openai', model_name: 'gpt-4o', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v2', temperature: 0.2, max_tokens: 65536, context_window: 128000, input_token_limit: 100000, token_budget: 500000, timeout_seconds: 90, max_retries: 3, is_active: true, created_at: '2026-04-05T00:00:00Z', updated_at: '2026-04-05T00:00:00Z' },
|
||||
{ id: 'var-2', agent_id: 'agent-1', variant_name: 'Mistral Variant', variant_slug: 'mistral-variant', description: 'Uses Mistral for extraction', model_provider: 'ollama', model_name: 'mistral:7b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', temperature: 0.0, max_tokens: 32768, context_window: 0, input_token_limit: 0, token_budget: 0, timeout_seconds: 120, max_retries: 2, is_active: false, created_at: '2026-04-06T00:00:00Z', updated_at: '2026-04-06T00:00:00Z' },
|
||||
];
|
||||
|
||||
export const mockVariantPerformance = {
|
||||
total_invocations: 50, successes: 45, failures: 5, avg_duration_ms: 1200, p95_duration_ms: 2500, avg_confidence: 0.85, avg_retries: 0.3, total_input_tokens: 50000, total_output_tokens: 15000, success_rate: 0.9,
|
||||
};
|
||||
|
||||
export const mockVariantPerfHistory = [
|
||||
{ hour: '2026-04-10T10:00:00Z', invocations: 10, successes: 9, avg_duration_ms: 1100, avg_confidence: 0.88 },
|
||||
{ hour: '2026-04-10T11:00:00Z', invocations: 12, successes: 11, avg_duration_ms: 1300, avg_confidence: 0.82 },
|
||||
];
|
||||
|
||||
export const handlers = [
|
||||
// Query API (proxied at /api/)
|
||||
http.get('/api/companies', () => HttpResponse.json(mockCompanies)),
|
||||
@@ -142,6 +161,54 @@ export const handlers = [
|
||||
}),
|
||||
http.get('/api/trends/:id/projection', () => HttpResponse.json(mockTrendProjection)),
|
||||
|
||||
// Agents
|
||||
http.get('/api/agents', () => HttpResponse.json(mockAgents)),
|
||||
http.get('/api/agents/:agent_id', ({ params }) => {
|
||||
const a = mockAgents.find((a) => a.id === params.agent_id);
|
||||
return a ? HttpResponse.json(a) : new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
http.get('/api/agents/:agent_id/performance', () => HttpResponse.json(mockVariantPerformance)),
|
||||
http.get('/api/agents/:agent_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
|
||||
|
||||
// Agent Variants
|
||||
http.get('/api/agents/:agent_id/variants', ({ params }) =>
|
||||
HttpResponse.json(mockAgentVariants.filter((v) => v.agent_id === params.agent_id)),
|
||||
),
|
||||
http.get('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
|
||||
const v = mockAgentVariants.find((v) => v.id === params.variant_id && v.agent_id === params.agent_id);
|
||||
return v ? HttpResponse.json(v) : new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
http.post('/api/agents/:agent_id/variants', async ({ params, request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 'var-new', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: body.variant_slug ?? 'new-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
|
||||
}),
|
||||
http.post('/api/agents/:agent_id/clone', async ({ params, request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 'var-cloned', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'cloned-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
|
||||
}),
|
||||
http.post('/api/agents/:agent_id/variants/:variant_id/clone', async ({ params, request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 'var-clone2', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'clone2', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
|
||||
}),
|
||||
http.put('/api/agents/:agent_id/variants/:variant_id', async ({ params, request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
|
||||
return v ? HttpResponse.json({ ...v, ...body, updated_at: '2026-04-10T12:00:00Z' }) : new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
http.delete('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
|
||||
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
|
||||
if (!v) return new HttpResponse(null, { status: 404 });
|
||||
if (v.is_active) return HttpResponse.json({ detail: 'Cannot delete active variant' }, { status: 400 });
|
||||
return HttpResponse.json({ status: 'deleted' });
|
||||
}),
|
||||
http.post('/api/agents/:agent_id/variants/:variant_id/activate', ({ params }) => {
|
||||
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
|
||||
return v ? HttpResponse.json({ ...v, is_active: true }) : new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
http.post('/api/agents/:agent_id/variants/deactivate', () => HttpResponse.json({ deactivated: true })),
|
||||
http.get('/api/agents/:agent_id/variants/:variant_id/performance', () => HttpResponse.json(mockVariantPerformance)),
|
||||
http.get('/api/agents/:agent_id/variants/:variant_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
|
||||
|
||||
// Competitive intelligence endpoints
|
||||
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
|
||||
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
|
||||
|
||||
@@ -168,3 +168,43 @@ describe('Global Events page', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agents page', () => {
|
||||
it('renders agent list in sidebar', async () => {
|
||||
renderRoute('/agents');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Document Extractor')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event Classifier')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders variant list when an agent is selected', async () => {
|
||||
renderRoute('/agents');
|
||||
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByText('Document Extractor'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mistral Variant')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows comparison view when multiple variants are checked', async () => {
|
||||
renderRoute('/agents');
|
||||
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByText('Document Extractor'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument());
|
||||
|
||||
// Select both variant checkboxes
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Variant Comparison/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user