[autoresearch] baseline: system prompt quality tests (5/5 passing)
This commit is contained in:
1 parent
05cc036f6d
commit
60b0101717
142 files changed
+18589
-9947
No files matched your search
@@ -0,0 +1,650 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "forgelm-chat",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"dompurify": "^3.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"highlight.js": "^11.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/[email protected]", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/[email protected]", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/[email protected]", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/[email protected]", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/[email protected]", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/[email protected]", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/[email protected]", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/[email protected]", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/[email protected]", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/[email protected]", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||
|
||||
"@phosphor-icons/react": ["@phosphor-icons/[email protected]", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/[email protected]", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
||||
|
||||
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/dompurify": ["@types/[email protected]", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/[email protected]", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/[email protected]", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="],
|
||||
|
||||
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
|
||||
|
||||
"bidi-js": ["[email protected]", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
|
||||
"chai": ["[email protected]", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"css-tree": ["[email protected]", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["[email protected]", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["[email protected]", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"es-module-lexer": ["[email protected]", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"highlight.js": ["[email protected]", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"html-encoding-sniffer": ["[email protected]", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-potential-custom-element-name": ["[email protected]", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["[email protected]", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["[email protected]", "", {}, "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ=="],
|
||||
|
||||
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="],
|
||||
|
||||
"mdn-data": ["[email protected]", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||
|
||||
"motion-utils": ["[email protected]", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"obug": ["[email protected]", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pify": ["[email protected]", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||
|
||||
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||
|
||||
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"saxes": ["[email protected]", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"siginfo": ["[email protected]", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["[email protected]", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["[email protected]", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"symbol-tree": ["[email protected]", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinybench": ["[email protected]", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["[email protected]", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinyrainbow": ["[email protected]", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["[email protected]", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
|
||||
|
||||
"tldts-core": ["[email protected]", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
|
||||
|
||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"tough-cookie": ["[email protected]", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"tr46": ["[email protected]", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["[email protected]", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="],
|
||||
|
||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vitest": ["[email protected]", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="],
|
||||
|
||||
"w3c-xmlserializer": ["[email protected]", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["[email protected]", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["[email protected]", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["[email protected]", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"why-is-node-running": ["[email protected]", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"xml-name-validator": ["[email protected]", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["[email protected]", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
|
||||
"vitest/vite": ["[email protected]", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
[
|
||||
"data: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"/com\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"port\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"amentos\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" específicos\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" |\"},\"finish_reason\":null}]}\n\ndata: {\"id",
|
||||
"data: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" fabricação\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" manual\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" desses\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" itens\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" individual\"},\"finish_reason\":null}]",
|
||||
"data: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Hid\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"rául\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ico\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"/E\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"let\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-0",
|
||||
"data: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" -->\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" B\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"{\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Sele\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-036b-440a-8b79-b23dd0834fac\",\"object\":\"chat.completion.chunk\",\"created\":1775246447,\"model\":\"Cooper-Q6_K\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"cionar\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"dbdf573d-",
|
||||
"(\\/\\*\\*(?!\\/))|(\\/\\*)|(\\/\\/)|(\\/(?=[^/\\n]*\\/))|((\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|[a-zA-Z_]\\w*)\\s*=>)|(,)|(\\s+)|(<>)|(<[A-Za-z0-9\\\\._:-]+\\s*\\/>)|(<[A-Za-z0-9\\\\._:-]+)|(\\B|\\b)",
|
||||
"(\\/\\*\\*(?!\\/))|(\\/\\*)|(\\/\\/)|(\\/(?=[^/\\n]*\\/))|((\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|[a-zA-Z_]\\w*)\\s*=>)|(,)|(\\s+)|(<>)|(<[A-Za-z0-9\\\\._:-]+\\s*\\/>)|(<[A-Za-z0-9\\\\._:-]+)|(\\B|\\b)",
|
||||
"(\\/\\*\\*(?!\\/))|(/\\*)|(//)|(\\/(?=[^/\\n]*\\/))|((\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|[a-zA-Z_]\\w*)\\s*=>)|(,)|(\\s+)|(<>)|(<[A-Za-z0-9\\\\._:-]+\\s*\\/>)|(<[A-Za-z0-9\\\\._:-]+)|(\\B|\\b)",
|
||||
"([ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):))|([ ]+((?:I|a|is|so|us|to|at|if|in|it|on|[A-Za-z]+['](d|ve|re|ll|t|s|n)|[A-Za-z]+[-][a-z]+|[A-Za-z][a-z]{2,})[.]?[:]?([.][ ]|[ ])){3})|($)",
|
||||
"%c[DevLogger] Chunk #350",
|
||||
"%c[DevLogger] Chunk #400",
|
||||
"%c[DevLogger] Chunk #450",
|
||||
"%c[DevLogger] Chunk #500",
|
||||
"dbdf573d-036b-440a-8b79-b23dd0834fac",
|
||||
"dbdf573d-036b-440a-8b79-b23dd0834fac",
|
||||
"dbdf573d-036b-440a-8b79-b23dd0834fac",
|
||||
"dbdf573d-036b-440a-8b79-b23dd0834fac",
|
||||
"2026-04-03T20:00:49.603Z",
|
||||
"chat.completion.chunk",
|
||||
"chat.completion.chunk",
|
||||
"2026-04-03T20:00:49.773Z",
|
||||
"chat.completion.chunk",
|
||||
"chat.completion.chunk",
|
||||
"2026-04-03T20:00:50.102Z",
|
||||
"2026-04-03T20:00:50.253Z",
|
||||
"HTMLQuoteElement",
|
||||
"<blockquote>\n",
|
||||
"</blockquote>\n",
|
||||
"<span class=\"",
|
||||
"isMatchIgnored",
|
||||
"(')|(\")|(\\])|($)",
|
||||
"Cooper-Q6_K",
|
||||
"Cooper-Q6_K",
|
||||
"language:",
|
||||
"language-",
|
||||
"Cooper-Q6_K",
|
||||
"Cooper-Q6_K",
|
||||
"get cite",
|
||||
"set cite",
|
||||
"mermaid",
|
||||
"(\\B|\\b)",
|
||||
"(\\B|\\b)",
|
||||
"(\\B|\\b)",
|
||||
"(\\B|\\b)",
|
||||
"(\\B|\\b)",
|
||||
"(\\B|\\b)",
|
||||
"uel"
|
||||
]
|
||||
Generated
-4676
File diff suppressed because it is too large.
Load diff
Generated
-1862
File diff suppressed because it is too large.
Load diff
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -6,6 +6,12 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ForgeLM Chat","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ForgeLM Chat","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-close","core:window:allow-is-maximized"]}}
|
||||
@@ -18,8 +18,9 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* Solid background here so transparent window still looks right */
|
||||
background: var(--bg);
|
||||
border-radius: 10px; /* matches Tauri window radius */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Offset entire layout below the fixed 48px header */
|
||||
.app-layout.with-app-header {
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
|
||||
@@ -120,6 +120,15 @@ function createMutableDbStub(): DB {
|
||||
return id;
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
|
||||
const chatId = Number(params?.[0]);
|
||||
const content = String(params?.[1] ?? "");
|
||||
const next = rowsByChat.get(chatId) ?? [];
|
||||
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
|
||||
rowsByChat.set(chatId, next);
|
||||
return nextMessageId - 1;
|
||||
}
|
||||
|
||||
return lastInsertRowId;
|
||||
}),
|
||||
query: vi.fn((sql: string, params?: unknown[]) => {
|
||||
@@ -225,6 +234,8 @@ describe("App chat history", () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
@@ -274,7 +285,7 @@ describe("App chat history", () => {
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
|
||||
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
|
||||
expect(sendButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -345,7 +356,7 @@ describe("App chat history", () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
const newChatButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("New Chat"));
|
||||
const newChatButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("New task")) as HTMLButtonElement | undefined;
|
||||
expect(newChatButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -362,7 +373,7 @@ describe("App chat history", () => {
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
|
||||
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
|
||||
expect(sendButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -410,4 +421,64 @@ describe("App chat history", () => {
|
||||
expect(container.textContent).toContain("message from sidebar-created chat");
|
||||
expect(container.textContent).toContain("reply from sidebar-created chat");
|
||||
});
|
||||
|
||||
it("keeps existing chat entries available after creating a new chat and sending the first message", async () => {
|
||||
const db = createMutableDbStub();
|
||||
vi.mocked(useDB).mockReturnValue({ db, ready: true });
|
||||
|
||||
let resolveFetch: ((value: Response) => void) | null = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
expect(setTextareaValue).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
setTextareaValue?.call(textarea, "fresh chat keeps sidebar history");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const sendButton = container.querySelector('[aria-label="Send message"]') as HTMLButtonElement | null;
|
||||
expect(sendButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
sendButton?.click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const encoder = new TextEncoder();
|
||||
resolveFetch?.(
|
||||
new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"assistant keeps new chat\"}}]}\n"));
|
||||
controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n"));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n"));
|
||||
controller.close();
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const chatButtons = Array.from(container.querySelectorAll("button")).map((node) => node.textContent ?? "");
|
||||
expect(chatButtons.some((text) => text.includes("Existing chat"))).toBe(true);
|
||||
expect(chatButtons.some((text) => text.includes("fresh chat keeps sidebar history"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/* ══════════════════════════════════════════════════════
|
||||
AppHeader — Manus-inspired custom titlebar
|
||||
48px tall · glass-dark · full drag region · custom WinControls
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 8px 0 10px;
|
||||
|
||||
/* Manus-dark glass */
|
||||
background: rgba(13, 13, 17, 0.90);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.03), 0 4px 28px rgba(0,0,0,0.5);
|
||||
|
||||
/* enable drag: the region itself is draggable; interactive children opt-out */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ── Left ── */
|
||||
.app-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Center ── */
|
||||
.app-header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* stop drag so user can interact with the pill */
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Right ── */
|
||||
.app-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Hamburger ── */
|
||||
.header-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.45);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.header-nav-btn:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9); }
|
||||
.header-nav-btn.active { background: rgba(124,58,237,0.18); color: #a78bfa; }
|
||||
|
||||
/* Back button — slightly wider to fit the arrow */
|
||||
.header-back-btn { width: 34px; gap: 3px; }
|
||||
.header-back-btn:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9); }
|
||||
|
||||
/* ── Brand ── */
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-brand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 2px 8px rgba(124,58,237,0.45));
|
||||
}
|
||||
|
||||
.header-brand-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.2px;
|
||||
background: linear-gradient(135deg, #e2d9f3 0%, #c4b5fd 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Breadcrumb ── */
|
||||
.header-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header-breadcrumb-sep {
|
||||
color: rgba(255,255,255,0.18);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-breadcrumb-title {
|
||||
font-size: 12.5px;
|
||||
color: rgba(255,255,255,0.38);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* ── Runtime status pill ── */
|
||||
.header-status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px 3px 6px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.header-status-pill.online { background: rgba(16,185,129,0.1); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.18); }
|
||||
.header-status-pill.offline { background: rgba(239,68,68,0.09); color: #fca5a5; border: 1px solid rgba(239,68,68,0.18); }
|
||||
|
||||
:root {
|
||||
--status-online: #10b981;
|
||||
--status-offline: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Live generation badge ── */
|
||||
.header-live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
background: rgba(99,102,241,0.14);
|
||||
border: 1px solid rgba(99,102,241,0.24);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #a5b4fc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-live-pulse {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #818cf8;
|
||||
flex-shrink: 0;
|
||||
animation: hdr-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hdr-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
}
|
||||
.header-live-unit { font-weight: 400; opacity: 0.7; }
|
||||
|
||||
/* ── TPS badge ── */
|
||||
.header-tps-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.35);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.header-tps-unit { font-size: 10px; opacity: 0.7; }
|
||||
|
||||
/* ── Icon buttons ── */
|
||||
.header-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.38);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.header-icon-btn:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.85); }
|
||||
.header-icon-btn:active { background: rgba(255,255,255,0.04); transform: scale(0.93); }
|
||||
|
||||
/* ── Window Controls ── */
|
||||
.header-wincontrols {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid rgba(255,255,255,0.06);
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.winctrl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.3);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.winctrl:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.85); }
|
||||
.winctrl:active { transform: scale(0.92); }
|
||||
|
||||
/* Close gets a red hover */
|
||||
.winctrl-close:hover {
|
||||
background: rgba(239,68,68,0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Content offset ── */
|
||||
.with-app-header { padding-top: 48px; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.app-header { padding: 0 4px 0 6px; }
|
||||
.header-tps-badge { display: none; }
|
||||
.header-breadcrumb-title { max-width: 90px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.header-breadcrumb { display: none; }
|
||||
.header-brand-name { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ModelInfo, ServerStatus, ChatTokenTotals } from "../lib/types";
|
||||
import type { RuntimeModelFile, RuntimeStatus } from "../features/runtime/types";
|
||||
import ModelSelectorPill from "./ModelSelectorPill";
|
||||
|
||||
async function tauriMinimize() {
|
||||
try { const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().minimize(); } catch { /**/ }
|
||||
}
|
||||
async function tauriMaximize() {
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const win = getCurrentWindow();
|
||||
if (await win.isMaximized()) { await win.unmaximize(); } else { await win.maximize(); }
|
||||
} catch { /**/ }
|
||||
}
|
||||
async function tauriClose() {
|
||||
try { const { getCurrentWindow } = await import("@tauri-apps/api/window"); await getCurrentWindow().close(); } catch { /**/ }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onBackToChat?: () => void;
|
||||
models: ModelInfo[];
|
||||
localModels: RuntimeModelFile[];
|
||||
selectedModel: string;
|
||||
onModelChange: (id: string) => void;
|
||||
onModelSwap?: (path: string) => void;
|
||||
isSwapping?: boolean;
|
||||
status: ServerStatus;
|
||||
runtimeStatus: RuntimeStatus;
|
||||
serverConnected: boolean;
|
||||
generating: boolean;
|
||||
streamTokenCount: number;
|
||||
tps: number | null;
|
||||
chatTitle?: string;
|
||||
chatTokenTotals: ChatTokenTotals;
|
||||
}
|
||||
|
||||
export default function AppHeader({
|
||||
sidebarOpen, onToggleSidebar, onOpenSettings, onBackToChat,
|
||||
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping,
|
||||
status, runtimeStatus, serverConnected,
|
||||
generating, streamTokenCount, tps, chatTitle, chatTokenTotals,
|
||||
}: Props) {
|
||||
const isOnline = serverConnected && runtimeStatus === "ready";
|
||||
|
||||
return (
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-[200] h-12 flex items-center gap-0 px-2
|
||||
bg-[rgba(13,13,17,0.92)] backdrop-blur-2xl
|
||||
border-b border-white/[0.055]
|
||||
shadow-[0_1px_0_rgba(255,255,255,0.03),0_4px_24px_rgba(0,0,0,0.5)]
|
||||
select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* ── Left ── */}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0" data-tauri-drag-region>
|
||||
{onBackToChat ? (
|
||||
/* Back button in settings */
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
aria-label="Back to chat"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white/90 hover:bg-white/[0.07] transition-colors flex-shrink-0"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
/* Sidebar toggle */
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
aria-expanded={sidebarOpen}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-colors flex-shrink-0
|
||||
${sidebarOpen ? "bg-violet-500/20 text-violet-400" : "text-white/40 hover:text-white/90 hover:bg-white/[0.07]"}`}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Context title */}
|
||||
<div className="min-w-0 flex items-center">
|
||||
<span className="text-[12.5px] text-white/78 truncate max-w-[320px] md:max-w-[420px]">
|
||||
{chatTitle || "New chat"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Center: model selector ── */}
|
||||
<div
|
||||
className="flex items-center justify-center flex-shrink-0"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ModelSelectorPill
|
||||
models={models} localModels={localModels}
|
||||
selectedModel={selectedModel} onModelChange={onModelChange}
|
||||
onModelSwap={onModelSwap} isSwapping={isSwapping} status={status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Right ── */}
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 justify-end"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* Totals */}
|
||||
{!generating && chatTokenTotals.total_tokens > 0 && (
|
||||
<div
|
||||
className="hidden md:flex items-center gap-1"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-full bg-white/[0.04] border border-white/[0.06] text-[10.5px] text-sky-300/90 tabular-nums">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
|
||||
<span>{chatTokenTotals.prompt_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-full bg-white/[0.04] border border-white/[0.06] text-[10.5px] text-violet-300/90 tabular-nums">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14"/><path d="M18 13l-6 6-6-6"/></svg>
|
||||
<span>{chatTokenTotals.completion_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-full bg-white/[0.055] border border-white/[0.08] text-[10.5px] text-white/78 tabular-nums">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/></svg>
|
||||
<span>{chatTokenTotals.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live generation badge */}
|
||||
{generating && streamTokenCount > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-indigo-500/14 border border-indigo-400/24 text-[11px] font-semibold text-indigo-300"
|
||||
aria-live="polite"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 animate-pulse flex-shrink-0" aria-hidden="true"/>
|
||||
{streamTokenCount.toLocaleString()}
|
||||
<span className="opacity-60 font-normal">tok</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TPS */}
|
||||
{tps !== null && !generating && (
|
||||
<span
|
||||
className="text-[11px] text-white/30 tabular-nums"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{tps.toFixed(1)}<span className="opacity-60"> tok/s</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status pill */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-[11px] font-medium border
|
||||
${isOnline
|
||||
? "bg-emerald-500/10 text-emerald-300 border-emerald-500/18"
|
||||
: "bg-red-500/9 text-red-300 border-red-500/18"}`}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isOnline ? "bg-emerald-400" : "bg-red-400"}`} aria-hidden="true"/>
|
||||
<span>{isOnline ? "Ready" : "Offline"}</span>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-white/35 hover:text-white/80 hover:bg-white/[0.07] transition-colors"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Window controls */}
|
||||
<div
|
||||
className="flex items-center gap-0.5 ml-1.5 pl-2 border-l border-white/[0.07]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button type="button" onClick={() => { void tauriMinimize(); }} aria-label="Minimize"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-white/25 hover:text-white/80 hover:bg-white/[0.07] transition-colors">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="1" y1="5" x2="9" y2="5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
<button type="button" onClick={() => { void tauriMaximize(); }} aria-label="Maximize"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-white/25 hover:text-white/80 hover:bg-white/[0.07] transition-colors">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
|
||||
</button>
|
||||
<button type="button" onClick={() => { void tauriClose(); }} aria-label="Close"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-white/25 hover:text-red-300 hover:bg-red-500/18 transition-colors">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -204,20 +204,35 @@
|
||||
.chat-token-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.7rem;
|
||||
gap: 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chat-token-summary span {
|
||||
background: var(--bg3);
|
||||
.token-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chat-token-summary .token-total {
|
||||
.token-chip strong {
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
.token-chip--sent {
|
||||
color: rgba(125, 211, 252, 0.9);
|
||||
}
|
||||
.token-chip--recv {
|
||||
color: rgba(167, 139, 250, 0.95);
|
||||
}
|
||||
.token-chip--total {
|
||||
color: rgba(244, 244, 245, 0.82);
|
||||
border-color: var(--border2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Live token counter */
|
||||
@@ -717,6 +732,14 @@
|
||||
color: #eef5ff;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Markdown inside bubbles */
|
||||
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3,
|
||||
.msg-bubble h4, .msg-bubble h5, .msg-bubble h6 {
|
||||
@@ -1161,6 +1184,8 @@
|
||||
.stop-btn:hover { background: rgba(240, 64, 64, 0.1); }
|
||||
|
||||
/* ── Initial screen / Manus-like landing ── */
|
||||
/* ── Hero / Initial Screen ── */
|
||||
|
||||
.chat-view.initial-screen {
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -1168,21 +1193,15 @@
|
||||
.chat-view.initial-screen .chat-topbar {
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
height: 56px;
|
||||
padding: 0 24px 0 18px;
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.chat-view.initial-screen .chat-topbar-center {
|
||||
display: flex;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-view.initial-screen .chat-topbar-right {
|
||||
margin-left: auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Hide runtime pill on initial screen — model badge is sufficient */
|
||||
.chat-view.initial-screen .topbar-state-pill--hero {
|
||||
.chat-view.initial-screen .topbar-state-pill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1194,269 +1213,196 @@
|
||||
}
|
||||
|
||||
.topbar-model-badge--hero .topbar-model-name {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text2);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Hero full-viewport centering */
|
||||
.messages-container--hero {
|
||||
min-height: calc(100dvh - 56px);
|
||||
padding: 62px 24px 40px;
|
||||
min-height: calc(100dvh - 52px);
|
||||
padding: 0 24px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state--hero {
|
||||
width: min(100%, 920px);
|
||||
/* Hero root container */
|
||||
.hero-screen {
|
||||
width: min(100%, 680px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.empty-state-panel--hero {
|
||||
/* Hero panel */
|
||||
.hero-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.empty-state-header--hero {
|
||||
align-items: center;
|
||||
/* Hero heading */
|
||||
.hero-header {
|
||||
text-align: center;
|
||||
max-width: 820px;
|
||||
gap: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state-header--hero h1 {
|
||||
font-family: inherit;
|
||||
font-size: clamp(3.3rem, 5vw, 4.4rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
.hero-header h1 {
|
||||
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text3);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero composer block */
|
||||
.hero-composer-block {
|
||||
width: min(100%, 760px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Hero input box — overrides base .input-box */
|
||||
.input-box--hero {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 14px 16px 12px;
|
||||
border-radius: 28px;
|
||||
background: rgba(18, 18, 20, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.input-box--hero:focus-within {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.hero-composer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-box-main {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-box-main--hero {
|
||||
gap: 14px;
|
||||
border-color: var(--border2);
|
||||
}
|
||||
|
||||
.input-box--hero textarea {
|
||||
min-height: 48px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
align-self: stretch;
|
||||
padding: 10px 0 6px;
|
||||
color: rgba(255, 255, 255, 0.98);
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 240px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-box--hero textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.attachment-actions--hero {
|
||||
align-self: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.attachment-actions--hero button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
/* Hero composer footer row (agentic toggle + send) */
|
||||
.hero-composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hero-composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-utility-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.send-btn--hero,
|
||||
.stop-btn--hero {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
.send-btn--hero {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.composer-footer-row--hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
.send-btn--hero:disabled {
|
||||
background: var(--bg4);
|
||||
color: var(--text3);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.composer-footer-hint--hero {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.82rem;
|
||||
.send-btn--hero:not(:disabled):hover {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.composer-footer-icons--hero {
|
||||
display: flex;
|
||||
.stop-btn--hero {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
background: var(--bg4);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border2);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.composer-footer-icons--hero span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
.stop-btn--hero:hover {
|
||||
background: var(--bg5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Starter chips */
|
||||
.starter-chip-rail {
|
||||
width: min(100%, 760px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.starter-chip--hero {
|
||||
.starter-chip {
|
||||
width: auto;
|
||||
padding: 10px 16px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(39, 39, 42, 0.96);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.starter-chip--hero:hover {
|
||||
background: rgba(63, 63, 70, 0.96);
|
||||
}
|
||||
|
||||
.hero-promo-card {
|
||||
width: min(100%, 760px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
padding: 18px 18px 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.hero-promo-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.hero-promo-title {
|
||||
color: #fff;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-promo-subtitle {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-promo-preview {
|
||||
width: 186px;
|
||||
min-width: 186px;
|
||||
height: 106px;
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
grid-template-rows: 10px 1fr;
|
||||
gap: 8px;
|
||||
background: linear-gradient(180deg, rgba(39, 39, 42, 0.98), rgba(24, 24, 27, 0.98));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hero-promo-preview-bar,
|
||||
.hero-promo-preview-pane,
|
||||
.hero-promo-preview .active {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hero-promo-preview-bar {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hero-promo-preview-pane {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.hero-promo-preview-pane--wide {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.hero-promo-preview .active {
|
||||
width: 44px;
|
||||
height: 10px;
|
||||
align-self: end;
|
||||
background: rgba(96, 165, 250, 0.44);
|
||||
.starter-chip:hover {
|
||||
background: var(--bg4);
|
||||
color: var(--text);
|
||||
border-color: var(--border2);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@@ -1484,15 +1430,11 @@
|
||||
.composer-notice { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
.messages-container--hero { padding: 56px 14px 32px; }
|
||||
.empty-state--hero { gap: 18px; }
|
||||
.empty-state-header--hero h1 { font-size: clamp(2.2rem, 8vw, 3.3rem); }
|
||||
.hero-composer-row { flex-direction: column; align-items: stretch; }
|
||||
.attachment-actions--hero { align-self: flex-start; }
|
||||
.input-box--hero { padding: 16px; border-radius: 24px; }
|
||||
.input-box--hero textarea { min-height: 72px; font-size: 15px; }
|
||||
.composer-footer-row--hero { flex-direction: column; align-items: flex-start; }
|
||||
.hero-promo-card { width: 100%; flex-direction: column; align-items: flex-start; }
|
||||
.hero-promo-preview { min-width: 100%; width: 100%; }
|
||||
.hero-screen { padding: 0 14px; }
|
||||
.input-box--hero { padding: 12px 14px 10px; }
|
||||
.input-box--hero textarea { min-height: 48px; font-size: 14px; }
|
||||
.starter-chip-rail { gap: 6px; }
|
||||
.starter-chip { padding: 7px 13px; font-size: 0.8rem; }
|
||||
}
|
||||
|
||||
/* ── Model Selector Pill (in input area) ── */
|
||||
@@ -1559,6 +1501,15 @@
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.model-selector-icon,
|
||||
.model-selector-item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.model-selector-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
@@ -1571,10 +1522,9 @@
|
||||
|
||||
.model-selector-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg2);
|
||||
@@ -1587,13 +1537,29 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.model-selector-menu--up {
|
||||
bottom: calc(100% + 8px);
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.model-selector-menu--down {
|
||||
top: calc(100% + 8px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.model-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.model-selector-item-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -16,6 +16,8 @@ import StreamingBubble from "./StreamingBubble";
|
||||
import { AgenticToggle } from "./forgeagent";
|
||||
// agentic-detection: shouldUseAgenticMode removed from auto-trigger — agentic mode is manual-only now
|
||||
import { Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight, Database, Lightning, Plus, Monitor, Globe, Palette, Code } from "@phosphor-icons/react";
|
||||
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
|
||||
import { MemoryModal } from "./memory/MemoryModal";
|
||||
import "./ChatView.css";
|
||||
|
||||
function scoreBrokenEncoding(text: string): number {
|
||||
@@ -43,8 +45,6 @@ function createStreamChunkDecoder() {
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_MEMORY = 20;
|
||||
|
||||
interface Props {
|
||||
db: DB;
|
||||
chatId: number | null;
|
||||
@@ -101,6 +101,14 @@ interface Props {
|
||||
const [debugCopied, setDebugCopied] = useState(false);
|
||||
const [agenticMode, setAgenticMode] = useState(false);
|
||||
|
||||
const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({
|
||||
db,
|
||||
server,
|
||||
apiKey,
|
||||
memoryModel: model,
|
||||
enabled: useMemory,
|
||||
});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -132,11 +140,8 @@ interface Props {
|
||||
return acc;
|
||||
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
|
||||
const memoryMessageCount = chatId != null
|
||||
? Math.min(
|
||||
messages.filter(m => m.content && !m.content.startsWith("⚠️") && m.content !== "internal error").length,
|
||||
useMemory ? MAX_MEMORY : 1,
|
||||
)
|
||||
const memoryMessageCount = showMemoryModal || useMemory
|
||||
? (db.get<{ c: number }>("SELECT COUNT(*) as c FROM user_memories")?.c ?? 0)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -302,11 +307,16 @@ interface Props {
|
||||
// Prepend a minimal system prompt so models like Qwen3 0.8B/2B/4B
|
||||
// understand they are a general-purpose assistant and don't refuse
|
||||
// benign queries like "teste" by imagining they're restricted.
|
||||
const memoryPrompt = buildMemorySystemPrompt();
|
||||
const systemContent = memoryPrompt
|
||||
? `${SYSTEM_PROMPT}\n\n${memoryPrompt}`
|
||||
: SYSTEM_PROMPT;
|
||||
|
||||
return [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "system", content: systemContent },
|
||||
...historyMessages,
|
||||
];
|
||||
}, [db]);
|
||||
}, [db, buildMemorySystemPrompt]);
|
||||
|
||||
const appendPersistedAssistantMessage = useCallback((message: Message) => {
|
||||
optimisticAssistantRef.current = message;
|
||||
@@ -624,6 +634,11 @@ interface Props {
|
||||
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
|
||||
onChatUpdated();
|
||||
|
||||
// Fire-and-forget: extract memories from this conversation turn
|
||||
if (useMemory && cid) {
|
||||
extractMemories(cid).catch(() => { /* non-critical */ });
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (err: unknown) {
|
||||
@@ -788,76 +803,12 @@ interface Props {
|
||||
loadMessages();
|
||||
};
|
||||
|
||||
const memoryMessages = showMemoryModal
|
||||
? db.query<UserMemory>("SELECT id, key, value, confidence, created_at, updated_at FROM user_memories ORDER BY updated_at DESC")
|
||||
: [];
|
||||
|
||||
const handleClearMemories = () => {
|
||||
if (!window.confirm("Clear all saved memories?")) return;
|
||||
db.run("DELETE FROM user_memories");
|
||||
setShowMemoryModal(false);
|
||||
};
|
||||
|
||||
const isInitialScreen = chatId === null && messages.length === 0;
|
||||
const quickActionChips = ["Create slides", "Build website", "Develop desktop apps", "Design", "More"];
|
||||
|
||||
return (
|
||||
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
|
||||
{/* ── Memory Modal ── */}
|
||||
{showMemoryModal && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/55 backdrop-blur-sm px-4" onClick={() => setShowMemoryModal(false)}>
|
||||
<div className="w-full max-w-2xl max-h-[78vh] overflow-hidden rounded-2xl border border-white/10 bg-[#1b1b1f] shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4 border-b border-white/8">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-white/92 text-sm font-semibold">
|
||||
<Database size={16} weight="duotone" className="text-blue-300" />
|
||||
<span>Saved memories</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] text-white/42">{memoryMessages.length} memórias salvas no modelo</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearMemories}
|
||||
className="px-3 py-1.5 rounded-lg border border-red-400/18 bg-red-500/8 text-[12px] font-medium text-red-300 hover:bg-red-500/14 transition-colors"
|
||||
>
|
||||
Limpar memórias
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMemoryModal(false)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white/85 hover:bg-white/8 transition-colors"
|
||||
aria-label="Fechar memórias"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(78vh-72px)] p-3 space-y-2">
|
||||
{memoryMessages.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-[13px] text-white/42">
|
||||
Nenhuma memória salva ainda.
|
||||
</div>
|
||||
) : (
|
||||
memoryMessages.map((memory) => (
|
||||
<div key={memory.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] uppercase tracking-[0.12em] text-white/30">{memory.key}</p>
|
||||
<p className="mt-1 text-[13px] text-white/82 leading-relaxed break-words">{memory.value}</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 px-2 py-1 rounded-full border border-white/8 bg-white/[0.04] text-[10.5px] text-white/45 tabular-nums">
|
||||
{(memory.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MemoryModal db={db} open={showMemoryModal} onClose={() => setShowMemoryModal(false)} />
|
||||
|
||||
{/* ── Chat Layout ── */}
|
||||
<div className="chat-layout-root">
|
||||
|
||||
@@ -4,6 +4,39 @@
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.msg-edit-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg-edit-toolbar__label {
|
||||
color: var(--text2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.msg-edit-toggle {
|
||||
background: var(--bg4);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.msg-edit-toggle.is-active {
|
||||
background: rgba(72, 166, 255, 0.12);
|
||||
color: var(--text);
|
||||
border-color: rgba(72, 166, 255, 0.4);
|
||||
}
|
||||
|
||||
.msg-edit-textarea {
|
||||
width: 100%;
|
||||
background: var(--bg3);
|
||||
@@ -20,6 +53,23 @@
|
||||
box-shadow: 0 0 0 3px rgba(72,166,255,0.1);
|
||||
}
|
||||
|
||||
.msg-edit-preview {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.msg-edit-preview__label {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text3);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -204,8 +254,51 @@
|
||||
.manus-markdown p { margin: 0.75em 0; color: rgba(228, 228, 231, 0.9); }
|
||||
|
||||
.manus-markdown ul,
|
||||
.manus-markdown ol { margin: 0.9em 0; padding-left: 1.35rem; }
|
||||
.manus-markdown li { margin: 0.32rem 0; color: rgba(228, 228, 231, 0.88); }
|
||||
.manus-markdown ol,
|
||||
.msg-edit-preview ul,
|
||||
.msg-edit-preview ol { margin: 0.9em 0; padding-left: 1.35rem; }
|
||||
.manus-markdown li,
|
||||
.msg-edit-preview li { margin: 0.32rem 0; color: rgba(228, 228, 231, 0.88); }
|
||||
|
||||
.manus-markdown .table-wrap,
|
||||
.msg-edit-preview .table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.manus-markdown table,
|
||||
.msg-edit-preview table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.manus-markdown th,
|
||||
.manus-markdown td,
|
||||
.msg-edit-preview th,
|
||||
.msg-edit-preview td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.manus-markdown th,
|
||||
.msg-edit-preview th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fafafa;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.manus-markdown tr:last-child td,
|
||||
.msg-edit-preview tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.manus-markdown blockquote {
|
||||
margin: 1.1em 0;
|
||||
@@ -333,6 +426,40 @@
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text .table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text th,
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fafafa;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.msg-wrapper.assistant .msg-bubble.msg-bubble--assistant-text code:not(.hljs) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -70,14 +70,13 @@ describe("MessageBubble", () => {
|
||||
expect(container.textContent).toContain("6.4 tok/s");
|
||||
});
|
||||
|
||||
it("renders a clean agentic final response without classic bubble chrome", async () => {
|
||||
it("renders assistant content inside the standard assistant bubble", async () => {
|
||||
const message = {
|
||||
id: 22,
|
||||
chat_id: 5,
|
||||
role: "assistant",
|
||||
content: "Aqui está o guia final.",
|
||||
created_at: Date.now(),
|
||||
display_mode: "agentic_final",
|
||||
} as Message;
|
||||
|
||||
await act(async () => {
|
||||
@@ -91,45 +90,18 @@ describe("MessageBubble", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".agent-final-response")).toBeTruthy();
|
||||
expect(container.querySelector(".msg-bubble--assistant-text")).toBeTruthy();
|
||||
expect(container.textContent).toContain("Aqui está o guia final.");
|
||||
});
|
||||
|
||||
it("keeps forgeagent execution cards visible for persisted agentic messages", async () => {
|
||||
it("shows markdown preview only while editing assistant messages", async () => {
|
||||
const message = {
|
||||
id: 31,
|
||||
chat_id: 7,
|
||||
id: 40,
|
||||
chat_id: 9,
|
||||
role: "assistant",
|
||||
content: "Aqui está a resposta final organizada.",
|
||||
content: "**Negrito**\n</think>",
|
||||
created_at: Date.now(),
|
||||
display_mode: "agentic_final",
|
||||
forgeagent_state: {
|
||||
plan_id: "plan-7",
|
||||
current_step: 3,
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
final_response: "Aqui está a resposta final organizada em formato Manus-style, pronta para revisão e entrega.",
|
||||
artifact: {
|
||||
filename: "forgeagent-output.md",
|
||||
content: "# Resultado final\n\nPrompt original: testar o agentico",
|
||||
language: "markdown",
|
||||
cta_label: "Entregar o guia ao usuário",
|
||||
},
|
||||
system_messages: [
|
||||
{ id: "m1", step_id: 1, tone: "info", content: "Analisei a solicitação e defini o objetivo principal." },
|
||||
{ id: "m2", step_id: 2, tone: "info", content: "Estruturei a solução e organizei a execução em blocos claros." },
|
||||
],
|
||||
plan: {
|
||||
title: "Pesquise sobre a qualidade das pedras nas montanhas",
|
||||
overview: "Vou estruturar um plano curto, executar cada etapa visualmente e entregar a resposta final no estilo ForgeAgent.",
|
||||
steps: [
|
||||
{ id: 1, title: "Entender a solicitação", description: "Quebrar o pedido em objetivo, stack e formato de entrega.", status: "completed", sub_steps: [] },
|
||||
{ id: 2, title: "Preparar a solução", description: "Montar a estrutura, dependências e fluxo recomendado.", status: "completed", sub_steps: [] },
|
||||
{ id: 3, title: "Gerar a resposta final", description: "Entregar o conteúdo final com orientação prática e artefato.", status: "completed", sub_steps: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as Message & { forgeagent_state: unknown };
|
||||
} as Message;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -142,13 +114,14 @@ describe("MessageBubble", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".forgeagent-steps--compact")).toBeTruthy();
|
||||
expect(container.textContent).toContain("Pesquise sobre a qualidade das pedras nas montanhas");
|
||||
expect(container.textContent).toContain("Entender a solicitação");
|
||||
expect(container.textContent).toContain("Analisei a solicitação e defini o objetivo principal.");
|
||||
expect(container.textContent).toContain("forgeagent-output.md");
|
||||
expect(container.textContent).toContain("Resposta final");
|
||||
expect(container.textContent).toContain("Concluí a execução do pedido");
|
||||
expect(container.textContent).not.toContain("pronta para revisão e entrega");
|
||||
const editButton = container.querySelector('[aria-label="Edit message"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
editButton.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Markdown during edit");
|
||||
expect(container.textContent).toContain("Preview");
|
||||
expect(container.textContent).toContain("Raw");
|
||||
expect(container.textContent).not.toContain("</think>");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import type { Message } from "../lib/types";
|
||||
import { sanitizeMessageText } from "../lib/messageSanitizer";
|
||||
import "./MessageBubble.css";
|
||||
|
||||
interface Props {
|
||||
@@ -12,8 +13,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onDelete, onEditAndResend, onEditAssistant, onResend, renderMarkdown }: Props) {
|
||||
const sanitizedMessageContent = sanitizeMessageText(message.content).text;
|
||||
const sanitizedReasoningContent = sanitizeMessageText(message.reasoning_content || "").text;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const [editValue, setEditValue] = useState(sanitizedMessageContent);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState<"preview" | "raw">("preview");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,7 +62,8 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditValue(message.content);
|
||||
setEditValue(sanitizedMessageContent);
|
||||
setMarkdownEditMode("preview");
|
||||
setEditing(true);
|
||||
setTimeout(() => { textareaRef.current?.focus(); autoResize(); }, 0);
|
||||
};
|
||||
@@ -66,7 +71,7 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
const cancelEdit = () => setEditing(false);
|
||||
|
||||
const saveEdit = (resend: boolean) => {
|
||||
const trimmed = editValue.trim();
|
||||
const trimmed = sanitizeMessageText(editValue).text.trim();
|
||||
if (!trimmed) return;
|
||||
setEditing(false);
|
||||
if (resend && onEditAndResend) {
|
||||
@@ -77,7 +82,7 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
};
|
||||
|
||||
const copyMessage = () => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
navigator.clipboard.writeText(sanitizedMessageContent).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
@@ -97,6 +102,23 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
<div className={`msg-wrapper ${message.role}`}>
|
||||
{editing ? (
|
||||
<div className="msg-edit-container">
|
||||
<div className="msg-edit-toolbar" role="toolbar" aria-label="Markdown editing options">
|
||||
<span className="msg-edit-toolbar__label">Markdown during edit</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`msg-edit-toggle ${markdownEditMode === "preview" ? "is-active" : ""}`}
|
||||
onClick={() => setMarkdownEditMode("preview")}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`msg-edit-toggle ${markdownEditMode === "raw" ? "is-active" : ""}`}
|
||||
onClick={() => setMarkdownEditMode("raw")}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="msg-edit-textarea"
|
||||
@@ -107,6 +129,15 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
if (e.key === "Enter" && e.ctrlKey) saveEdit(false);
|
||||
}}
|
||||
/>
|
||||
{markdownEditMode === "preview" && (
|
||||
<div className="msg-edit-preview">
|
||||
<div className="msg-edit-preview__label">Preview</div>
|
||||
<div
|
||||
className="msg-content msg-content--assistant manus-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(editValue) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="msg-edit-actions">
|
||||
{onEditAndResend && (
|
||||
<button className="btn-primary" onClick={() => saveEdit(true)}>
|
||||
@@ -137,14 +168,14 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
<div
|
||||
className="thinking-content"
|
||||
ref={thinkingRef}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(sanitizedReasoningContent) }}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="msg-content msg-content--assistant"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(sanitizedMessageContent) }}
|
||||
/>
|
||||
|
||||
{showFooter && (
|
||||
@@ -203,7 +234,7 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
|
||||
) : (
|
||||
<>
|
||||
<div className="msg-bubble user-bubble">
|
||||
<div className="msg-content">{message.content}</div>
|
||||
<div className="msg-content">{sanitizedMessageContent}</div>
|
||||
</div>
|
||||
<div className="msg-actions compact-actions" aria-label="Message actions">
|
||||
<button type="button" className={`action-btn ${copied ? "copied" : ""}`} onClick={copyMessage} aria-label="Copy message">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { Bot, Brain, ChevronDown, Cpu, Gem, Loader2, Search, Sparkles, Wind } from "lucide-react";
|
||||
import type { ModelInfo, ServerStatus } from "../lib/types";
|
||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||
import { getDisplayName } from "../lib/modelAlias";
|
||||
@@ -24,6 +24,7 @@ export default function ModelSelectorPill({
|
||||
status,
|
||||
}: ModelSelectorPillProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [openDirection, setOpenDirection] = useState<"up" | "down">("down");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +34,14 @@ export default function ModelSelectorPill({
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
const el = dropdownRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const estimatedMenuHeight = 260;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
setOpenDirection(spaceBelow >= estimatedMenuHeight || spaceBelow >= spaceAbove ? "down" : "up");
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
@@ -48,6 +57,17 @@ export default function ModelSelectorPill({
|
||||
}
|
||||
};
|
||||
|
||||
const getModelIcon = (label: string) => {
|
||||
const l = label.toLowerCase();
|
||||
if (l.includes("qwen")) return <Brain size={13} strokeWidth={2} />;
|
||||
if (l.includes("deepseek")) return <Search size={13} strokeWidth={2} />;
|
||||
if (l.includes("mistral")) return <Wind size={13} strokeWidth={2} />;
|
||||
if (l.includes("gemma") || l.includes("gemini")) return <Gem size={13} strokeWidth={2} />;
|
||||
if (l.includes("phi")) return <Sparkles size={13} strokeWidth={2} />;
|
||||
if (l.includes("llama")) return <Bot size={13} strokeWidth={2} />;
|
||||
return <Cpu size={13} strokeWidth={2} />;
|
||||
};
|
||||
|
||||
// Unified model list: merge API models + local scanned files
|
||||
const allAvailableModels = [
|
||||
...models.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
|
||||
@@ -79,12 +99,15 @@ export default function ModelSelectorPill({
|
||||
{isSwapping ? (
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<span className="model-selector-label">{selectedLabel}</span>
|
||||
<>
|
||||
<span className="model-selector-icon" aria-hidden="true">{getModelIcon(selectedLabel)}</span>
|
||||
<span className="model-selector-label">{selectedLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronDown size={12} strokeWidth={2} className="model-selector-chevron" />
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<ul className="model-selector-menu" role="listbox">
|
||||
<ul className={`model-selector-menu model-selector-menu--${openDirection}`} role="listbox">
|
||||
{dedupedModels.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
@@ -93,7 +116,8 @@ export default function ModelSelectorPill({
|
||||
className={`model-selector-item${m.id === selectedModel ? " model-selector-item--selected" : ""}`}
|
||||
onClick={() => handleSelectModel(m.id)}
|
||||
>
|
||||
{m.label}
|
||||
<span className="model-selector-item-icon" aria-hidden="true">{getModelIcon(m.label)}</span>
|
||||
<span className="model-selector-item-label">{m.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -4,45 +4,25 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Sidebar from "./Sidebar";
|
||||
import type { Chat, ModelInfo } from "../lib/types";
|
||||
import type { RuntimeModelFile } from "../features/runtime/types";
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
import type { Chat } from "../lib/types";
|
||||
|
||||
const defaultChats: Chat[] = [
|
||||
{ id: 1, title: "Chat One", created_at: 1, updated_at: 2 },
|
||||
];
|
||||
|
||||
const defaultApiModels: ModelInfo[] = [
|
||||
{ id: "api-model-1", object: "model", created: 1, owned_by: "local" },
|
||||
];
|
||||
|
||||
const defaultLocalModels: RuntimeModelFile[] = [
|
||||
{ name: "local-model-1.gguf", path: "/models/local-model-1.gguf" },
|
||||
{ name: "local-model-2.gguf", path: "/models/local-model-2.gguf" },
|
||||
{ id: 1, title: "Fix runtime error", created_at: 1, updated_at: 2 },
|
||||
{ id: 2, title: "Landing page redesign", created_at: 3, updated_at: 4 },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
chats: defaultChats,
|
||||
currentChatId: 1,
|
||||
initialScreen: false,
|
||||
onSelectChat: vi.fn(),
|
||||
onNewChat: vi.fn(),
|
||||
onDeleteChat: vi.fn(),
|
||||
models: defaultApiModels,
|
||||
localModels: defaultLocalModels,
|
||||
selectedModel: "api-model-1",
|
||||
onModelChange: vi.fn(),
|
||||
onModelSwap: vi.fn(),
|
||||
isSwapping: false,
|
||||
status: "ok" as const,
|
||||
onRefreshModels: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onCloseMobile: vi.fn(),
|
||||
};
|
||||
|
||||
// ─── Test harness ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Sidebar model dropdown", () => {
|
||||
describe("Sidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -69,93 +49,70 @@ describe("Sidebar model dropdown", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("renders both API models and local scanned models in the dropdown", () => {
|
||||
it("renders workspace navigation and project history", () => {
|
||||
renderSidebar();
|
||||
|
||||
// Open the dropdown
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
|
||||
expect(trigger).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
trigger?.click();
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
// API model should be present
|
||||
expect(text).toContain("api-model-1");
|
||||
// Local models should also be present
|
||||
expect(text).toContain("local-model-1.gguf");
|
||||
expect(text).toContain("local-model-2.gguf");
|
||||
expect(text).toContain("Agents");
|
||||
expect(text).toContain("Search");
|
||||
expect(text).toContain("Library");
|
||||
expect(text).toContain("Fix runtime error");
|
||||
expect(text).toContain("Landing page redesign");
|
||||
});
|
||||
|
||||
it("shows the selected model label in the trigger", () => {
|
||||
renderSidebar({ selectedModel: "/models/local-model-1.gguf" });
|
||||
it("calls onNewChat from the primary action", () => {
|
||||
const onNewChat = vi.fn();
|
||||
renderSidebar({ onNewChat });
|
||||
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
|
||||
expect(trigger?.textContent).toContain("local-model-1.gguf");
|
||||
const button = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes("New task"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(button).toBeTruthy();
|
||||
act(() => button?.click());
|
||||
expect(onNewChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onModelSwap when a model is selected from the dropdown", () => {
|
||||
const onModelSwap = vi.fn();
|
||||
const onModelChange = vi.fn();
|
||||
renderSidebar({ onModelSwap, onModelChange });
|
||||
it("calls onSelectChat when a project is clicked", () => {
|
||||
const onSelectChat = vi.fn();
|
||||
renderSidebar({ onSelectChat });
|
||||
|
||||
// Open the dropdown
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
|
||||
expect(trigger).toBeTruthy();
|
||||
const button = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes("Landing page redesign"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
act(() => {
|
||||
trigger?.click();
|
||||
});
|
||||
|
||||
// Click a local model option
|
||||
const options = container.querySelectorAll('[role="option"]');
|
||||
const localOption = Array.from(options).find((el) => el.textContent?.includes("local-model-1.gguf"));
|
||||
expect(localOption).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
(localOption as HTMLElement)?.click();
|
||||
});
|
||||
|
||||
// Both onModelChange and onModelSwap should be called
|
||||
expect(onModelChange).toHaveBeenCalledWith("/models/local-model-1.gguf");
|
||||
expect(onModelSwap).toHaveBeenCalledWith("/models/local-model-1.gguf");
|
||||
expect(button).toBeTruthy();
|
||||
act(() => button?.click());
|
||||
expect(onSelectChat).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("shows a spinner when isSwapping is true", () => {
|
||||
renderSidebar({ isSwapping: true });
|
||||
it("calls onDeleteChat from the delete control", () => {
|
||||
const onDeleteChat = vi.fn();
|
||||
renderSidebar({ onDeleteChat });
|
||||
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
|
||||
expect(trigger).toBeTruthy();
|
||||
// The spinner should be present inside the trigger
|
||||
const spinner = trigger?.querySelector(".animate-spin");
|
||||
expect(spinner).toBeTruthy();
|
||||
const deleteButton = container.querySelector('[aria-label="Delete conversation Landing page redesign"]') as HTMLButtonElement | null;
|
||||
expect(deleteButton).toBeTruthy();
|
||||
|
||||
act(() => deleteButton?.click());
|
||||
expect(onDeleteChat).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("deduplicates models when API and local models share the same id", () => {
|
||||
const apiModels: ModelInfo[] = [
|
||||
{ id: "/models/local-model-1.gguf", object: "model", created: 1, owned_by: "local" },
|
||||
];
|
||||
renderSidebar({ models: apiModels });
|
||||
it("opens settings from the footer", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderSidebar({ onOpenSettings });
|
||||
|
||||
// Open the dropdown
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLElement | null;
|
||||
expect(trigger).toBeTruthy();
|
||||
const settingsButton = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes("Settings"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
act(() => {
|
||||
trigger?.click();
|
||||
});
|
||||
|
||||
// Count how many times "local-model-1.gguf" appears as an option
|
||||
const options = container.querySelectorAll('[role="option"]');
|
||||
const matchingOptions = Array.from(options).filter((el) => el.textContent?.includes("local-model-1.gguf"));
|
||||
expect(matchingOptions).toHaveLength(1);
|
||||
expect(settingsButton).toBeTruthy();
|
||||
act(() => settingsButton?.click());
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the dropdown when no models are available", () => {
|
||||
renderSidebar({ models: [], localModels: [] });
|
||||
|
||||
const trigger = container.querySelector('[aria-label="Choose active model"]') as HTMLButtonElement | null;
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
it("shows the empty state when there are no chats", () => {
|
||||
renderSidebar({ chats: [] });
|
||||
expect(container.textContent).toContain("No tasks yet.");
|
||||
expect(container.textContent).toContain("Start a new task above.");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { Chat } from "../lib/types";
|
||||
import { Plus, X, Trash2, Bot, Search, Library, PanelLeft, FileCode2, ShieldAlert, Sparkles, FolderKanban, Settings2 } from "lucide-react";
|
||||
import "./Sidebar.css";
|
||||
import { GithubLogo, Globe, Gear, ArrowSquareOut } from "@phosphor-icons/react";
|
||||
|
||||
interface Props {
|
||||
chats: Chat[];
|
||||
@@ -14,138 +13,232 @@ interface Props {
|
||||
onCloseMobile: () => void;
|
||||
}
|
||||
|
||||
function TaskEmoji({ title }: { title: string }) {
|
||||
const t = title.toLowerCase();
|
||||
if (/error|bug|fix|crash|fail/.test(t)) return <>🐛</>;
|
||||
if (/design|ui|css|landing|visual/.test(t)) return <>✨</>;
|
||||
if (/api|backend|server|node|express/.test(t)) return <>⚙️</>;
|
||||
if (/deploy|docker|cloud|aws|vps|vercel/.test(t)) return <>🚀</>;
|
||||
if (/sql|database|db|postgres/.test(t)) return <>🗄️</>;
|
||||
if (/python|js|ts|code|script/.test(t)) return <>💻</>;
|
||||
if (/search|pesquise|busca/.test(t)) return <>🔍</>;
|
||||
if (/optim|performance|speed/.test(t)) return <>⚡</>;
|
||||
if (/ai|llm|model|ml/.test(t)) return <>🤖</>;
|
||||
return <>💬</>;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
chats, currentChatId, initialScreen = false, onSelectChat, onNewChat, onDeleteChat,
|
||||
onOpenSettings,
|
||||
onCloseMobile,
|
||||
chats, currentChatId, initialScreen = false,
|
||||
onSelectChat, onNewChat, onDeleteChat, onOpenSettings, onCloseMobile,
|
||||
}: Props) {
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
|
||||
const workspaceNavItems = [
|
||||
{ label: "Agents", icon: Bot, badge: "New" },
|
||||
{ label: "Search", icon: Search },
|
||||
{ label: "Library", icon: Library },
|
||||
];
|
||||
|
||||
const getTaskIcon = (title: string) => {
|
||||
const normalized = title.toLowerCase();
|
||||
if (normalized.includes("error") || normalized.includes("fix") || normalized.includes("bug")) return ShieldAlert;
|
||||
if (normalized.includes("design") || normalized.includes("ui") || normalized.includes("landing")) return Sparkles;
|
||||
if (normalized.includes("project") || normalized.includes("plan") || normalized.includes("roadmap")) return FolderKanban;
|
||||
return FileCode2;
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText("ForgeLM — local-first AI workspace");
|
||||
setShareCopied(true);
|
||||
window.setTimeout(() => setShareCopied(false), 1800);
|
||||
} catch {
|
||||
setShareCopied(false);
|
||||
}
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`sidebar${initialScreen ? " sidebar--initial" : ""}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<PanelLeft size={15} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<span className="brand-name">ForgeLM</span>
|
||||
</div>
|
||||
<aside className={`
|
||||
w-[260px] min-w-[260px] h-full flex flex-col overflow-hidden
|
||||
bg-[#1a1a1a] border-r border-white/[0.05]
|
||||
${initialScreen ? "" : ""}
|
||||
`}>
|
||||
|
||||
{/* ── Brand header ── */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg width="26" height="26" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<rect width="36" height="36" rx="9" fill="url(#sb-grad)" />
|
||||
<path d="M10 27L18 9l8 18" stroke="white" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M13.5 22h9" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="sb-grad" x1="0" y1="0" x2="36" y2="36">
|
||||
<stop offset="0%" stopColor="#7c3aed"/>
|
||||
<stop offset="100%" stopColor="#4f46e5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className="text-[14px] font-semibold text-white tracking-tight">ForgeLM</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-header-actions">
|
||||
<button
|
||||
className="icon-btn sidebar-close-btn"
|
||||
onClick={onCloseMobile}
|
||||
title="Close navigation"
|
||||
aria-label="Close navigation"
|
||||
type="button"
|
||||
>
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-actions sidebar-actions--top">
|
||||
<button className="new-chat-btn" onClick={onNewChat} type="button">
|
||||
<Plus size={16} strokeWidth={1.8} />
|
||||
<span>New task</span>
|
||||
{/* Panel toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseMobile}
|
||||
aria-label="Close sidebar"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md text-white/40 hover:text-white/80 hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M9 3v18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-nav-section">
|
||||
<div className="sidebar-nav-list" aria-label="Workspace navigation">
|
||||
{workspaceNavItems.map(({ label, icon: Icon, badge }) => (
|
||||
<button key={label} type="button" className="sidebar-nav-item">
|
||||
<Icon size={15} strokeWidth={1.8} />
|
||||
<span>{label}</span>
|
||||
{badge ? <span className="sidebar-nav-badge">{badge}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Top nav ── */}
|
||||
<nav className="px-2 pt-3 pb-1 flex-shrink-0 space-y-0.5">
|
||||
{/* New task — accent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13.5px] font-medium text-white/90 hover:bg-white/[0.06] transition-colors text-left"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<span className="sr-only">New task</span>
|
||||
<span>Novo chat</span>
|
||||
</button>
|
||||
|
||||
<div className="sidebar-list-meta">
|
||||
<div className="chat-list-section-label">Projects</div>
|
||||
<div className="sidebar-list-filter">All tasks</div>
|
||||
</div>
|
||||
<button type="button" className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13.5px] text-white/60 hover:text-white/90 hover:bg-white/[0.06] transition-colors text-left">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
Agents
|
||||
</button>
|
||||
|
||||
<nav className="chat-list" aria-label="Conversation history">
|
||||
{chats.length === 0 && (
|
||||
<div className="chat-list-empty">
|
||||
No tasks yet.<br />Start a new task above.
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13.5px] text-white/60 hover:text-white/90 hover:bg-white/[0.06] transition-colors text-left">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<span className="sr-only">Search</span>
|
||||
<span>Pesquisa</span>
|
||||
</button>
|
||||
|
||||
{chats.map((chat) => {
|
||||
const TaskIcon = getTaskIcon(chat.title);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`chat-item ${chat.id === currentChatId ? "active" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-item-button"
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
aria-current={chat.id === currentChatId ? "page" : undefined}
|
||||
>
|
||||
<span className="chat-item-icon" aria-hidden="true">
|
||||
<TaskIcon size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="chat-item-title">{chat.title}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="chat-item-del"
|
||||
onClick={() => onDeleteChat(chat.id)}
|
||||
title="Delete conversation"
|
||||
aria-label={`Delete conversation ${chat.title}`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13.5px] text-white/60 hover:text-white/90 hover:bg-white/[0.06] transition-colors text-left">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
<span className="sr-only">Library</span>
|
||||
<span>Biblioteca</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" className="sidebar-share-btn" onClick={() => { void handleShare(); }}>
|
||||
{shareCopied ? "Link copied" : "Share ForgeLM with a friend"}
|
||||
{/* ── Projects ── */}
|
||||
<div className="flex items-center justify-between px-3 pt-4 pb-1 flex-shrink-0">
|
||||
<span className="text-[11px] font-semibold text-white/30 uppercase tracking-widest">Projects</span>
|
||||
<button type="button" aria-label="New project" className="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-white/70 transition-colors">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer-copy">
|
||||
<span className="footer-meta">Local-first workspace</span>
|
||||
<button type="button" className="sidebar-footer-link" onClick={onOpenSettings}>
|
||||
<Settings2 size={12} strokeWidth={1.8} />
|
||||
<span>Settings</span>
|
||||
<div className="px-2 flex-shrink-0">
|
||||
<button type="button" className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13.5px] text-white/60 hover:text-white/90 hover:bg-white/[0.06] transition-colors text-left">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<path d="M12 11v6M9 14h6"/>
|
||||
</svg>
|
||||
New project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── All tasks header ── */}
|
||||
<div className="flex items-center justify-between px-3 pt-4 pb-1 flex-shrink-0">
|
||||
<span className="text-[11px] font-semibold text-white/30 uppercase tracking-widest">All tasks</span>
|
||||
<button type="button" aria-label="Filter" className="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-white/70 transition-colors">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<path d="M21 6H3M16 12H8M11 18H13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Chat list ── */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10" aria-label="Task history">
|
||||
{chats.length === 0 && (
|
||||
<p className="text-center text-[12px] text-white/25 px-2 py-6 leading-relaxed">
|
||||
No tasks yet.<br/>Start a new task above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onMouseEnter={() => setHoveredId(chat.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className={`
|
||||
group flex items-center rounded-xl pr-1 transition-colors
|
||||
${chat.id === currentChatId
|
||||
? "bg-white/[0.07] text-white"
|
||||
: "text-white/60 hover:bg-white/[0.05] hover:text-white/90"}
|
||||
`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
aria-current={chat.id === currentChatId ? "page" : undefined}
|
||||
className="flex-1 min-w-0 flex items-center gap-2.5 px-3 py-[8px] text-left"
|
||||
>
|
||||
<span className="text-[14px] flex-shrink-0 leading-none" aria-hidden="true">
|
||||
<TaskEmoji title={chat.title} />
|
||||
</span>
|
||||
<span className="truncate text-[13px] leading-snug">{chat.title}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteChat(chat.id); }}
|
||||
title="Delete"
|
||||
aria-label={`Delete conversation ${chat.title}`}
|
||||
className={`flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-lg transition-colors ${
|
||||
hoveredId === chat.id || chat.id === currentChatId
|
||||
? "text-white/30 hover:text-red-400 hover:bg-red-500/10"
|
||||
: "text-transparent hover:text-red-400 hover:bg-red-500/10"
|
||||
}`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── Footer links ── */}
|
||||
<div className="flex-shrink-0 px-3 pb-3 pt-2 border-t border-white/[0.05] space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="button" onClick={onOpenSettings} title="Settings" aria-label="Open settings" className="w-8 h-8 flex items-center justify-center rounded-lg text-white/35 hover:text-white/80 hover:bg-white/[0.06] transition-colors">
|
||||
<span className="sr-only">Settings</span>
|
||||
<Gear size={16} weight="duotone" />
|
||||
</button>
|
||||
<span className="text-[11px] text-white/25 select-none">from <strong className="font-semibold text-white/55">evandro.dev.br</strong></span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/[0.05] bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<GithubLogo size={16} weight="duotone" className="text-white/60 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-white/30">GitHub</p>
|
||||
<p className="truncate text-[12.5px] text-white/78">github.com/evandrodevbr</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://github.com/evandrodevbr")}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-white/[0.08] bg-white/[0.04] text-[11.5px] font-medium text-white/72 hover:bg-white/[0.08] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowSquareOut size={13} weight="duotone" />
|
||||
<span>Abrir</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/[0.05] bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe size={16} weight="duotone" className="text-white/60 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-white/30">Website</p>
|
||||
<p className="truncate text-[12.5px] text-white/78">evandro.dev.br</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://evandro.dev.br")}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-white/[0.08] bg-white/[0.04] text-[11.5px] font-medium text-white/72 hover:bg-white/[0.08] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowSquareOut size={13} weight="duotone" />
|
||||
<span>Abrir</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Live streaming assistant bubble */
|
||||
|
||||
.streaming-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Match the final message thinking style instead of a purple card */
|
||||
.stream-thinking-block {
|
||||
width: 100%;
|
||||
max-width: min(100%, 72ch);
|
||||
padding: 0 0 10px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-thinking-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stream-thinking-pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.18);
|
||||
animation: stream-pulse 1.25s infinite;
|
||||
}
|
||||
|
||||
.stream-thinking-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.stream-thinking-body {
|
||||
padding: 4px 0 0 28px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||
margin-left: 16px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.stream-thinking-placeholder {
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.word-stream-token {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.72), rgba(255,255,255,0.56));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: word-fade-in 420ms ease forwards;
|
||||
}
|
||||
|
||||
.stream-content-bubble {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-cursor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stream-loading-dots span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(196, 181, 253, 0.9);
|
||||
animation: stream-dot-bounce 1.1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.stream-loading-dots span:nth-child(2) { animation-delay: 0.14s; }
|
||||
.stream-loading-dots span:nth-child(3) { animation-delay: 0.28s; }
|
||||
|
||||
@keyframes stream-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.18); opacity: 1; }
|
||||
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); opacity: 0.55; }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes word-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-cursor {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes stream-dot-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.55;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
|
||||
|
||||
describe("chat view state helpers", () => {
|
||||
it("asks for an API endpoint before sending when no server is configured", () => {
|
||||
it("asks for a runtime connection before sending when no server is configured", () => {
|
||||
expect(getComposerNotice("model.gguf", "")).toMatchObject({
|
||||
tone: "warning",
|
||||
title: "Connect an API",
|
||||
actionLabel: "Show runtime panel",
|
||||
title: "Connect runtime",
|
||||
actionLabel: "Open sidebar",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("chat view state helpers", () => {
|
||||
expect(getComposerNotice("", "http://127.0.0.1:8080")).toMatchObject({
|
||||
tone: "info",
|
||||
title: "Choose a model",
|
||||
actionLabel: "Show runtime panel",
|
||||
actionLabel: "Open sidebar",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,11 @@ describe("chat view state helpers", () => {
|
||||
expect(getComposerNotice("model.gguf", "http://127.0.0.1:8080")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers starter prompts tailored to the local-first workspace", () => {
|
||||
it("offers starter prompts tailored to the current local-first workspace", () => {
|
||||
expect(getSuggestedPrompts()).toEqual([
|
||||
"Summarize this repository architecture.",
|
||||
"Help me debug why the local runtime is not ready.",
|
||||
"Draft a cleaner system prompt for this coding workflow.",
|
||||
"Inspect the repo structure and runtime flow.",
|
||||
"Diagnose why the local model is not ready.",
|
||||
"Draft a tighter system prompt for this workspace.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import c from "highlight.js/lib/languages/c";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import go from "highlight.js/lib/languages/go";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import rust from "highlight.js/lib/languages/rust";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import { ensureHljs, renderMarkdown } from "../../lib/markdown";
|
||||
|
||||
interface ArtifactViewerProps {
|
||||
artifact: {
|
||||
filename: string;
|
||||
content: string;
|
||||
language?: string;
|
||||
};
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
let codeHlReady = false;
|
||||
|
||||
function ensureCodeHljs() {
|
||||
if (codeHlReady) return;
|
||||
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("rust", rust);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("go", go);
|
||||
hljs.registerLanguage("cpp", cpp);
|
||||
hljs.registerLanguage("c", c);
|
||||
codeHlReady = true;
|
||||
}
|
||||
|
||||
function getExtension(filename: string) {
|
||||
const trimmed = filename.trim().toLowerCase();
|
||||
const parts = trimmed.split(".");
|
||||
if (parts.length < 2) return "";
|
||||
return parts[parts.length - 1] ?? "";
|
||||
}
|
||||
|
||||
function getLanguage(filename: string, language?: string) {
|
||||
if (language) return language.toLowerCase();
|
||||
const ext = getExtension(filename);
|
||||
const map: Record<string, string> = {
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
css: "css",
|
||||
html: "xml",
|
||||
xml: "xml",
|
||||
json: "json",
|
||||
sql: "sql",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
};
|
||||
return map[ext] ?? ext ?? "plaintext";
|
||||
}
|
||||
|
||||
function fileTypeTone(ext: string) {
|
||||
switch (ext) {
|
||||
case "md":
|
||||
case "markdown":
|
||||
return "from-sky-400/20 to-cyan-400/10 text-sky-300 border-sky-500/20";
|
||||
case "json":
|
||||
return "from-emerald-400/20 to-teal-400/10 text-emerald-300 border-emerald-500/20";
|
||||
case "ts":
|
||||
case "tsx":
|
||||
case "js":
|
||||
case "jsx":
|
||||
return "from-indigo-400/20 to-violet-400/10 text-indigo-300 border-indigo-500/20";
|
||||
case "py":
|
||||
return "from-amber-400/20 to-orange-400/10 text-amber-300 border-amber-500/20";
|
||||
default:
|
||||
return "from-zinc-400/20 to-zinc-500/10 text-zinc-300 border-zinc-700";
|
||||
}
|
||||
}
|
||||
|
||||
function FileGlyph({ filename }: { filename: string }) {
|
||||
const ext = getExtension(filename);
|
||||
const tone = fileTypeTone(ext);
|
||||
|
||||
if (ext === "md" || ext === "markdown") {
|
||||
return (
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
|
||||
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
|
||||
<path d="M10 11.25h4" strokeLinecap="round" />
|
||||
<path d="M10 14.25h7" strokeLinecap="round" />
|
||||
<path d="M10 17.25h5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (["ts", "tsx", "js", "jsx", "c", "cpp", "go", "rs", "py"].includes(ext)) {
|
||||
return (
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M9 8 5 12l4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="m15 8 4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === "json") {
|
||||
return (
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M9 8c-1.3 0-2 .7-2 2v1c0 .7-.3 1.3-.9 1.7-.6.4-1.1 1-.1 1.4.8.3 1 .8 1 1.5v1c0 1.3.7 2 2 2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15 8c1.3 0 2 .7 2 2v1c0 .7.3 1.3.9 1.7.6.4 1.1 1 .1 1.4-.8.3-1 .8-1 1.5v1c0 1.3-.7 2-2 2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl border bg-gradient-to-br ${tone}`}>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M7 3.75h7l4.25 4.25V20.25H7z" strokeLinejoin="round" />
|
||||
<path d="M14 3.75v4.5h4.25" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadFile(filename: string, content: string, language?: string) {
|
||||
const safeFilename = filename || "artifact.txt";
|
||||
const lower = safeFilename.toLowerCase();
|
||||
const mime =
|
||||
language === "markdown" || lower.endsWith(".md")
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = safeFilename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function ArtifactViewer({ artifact, onDownload }: ArtifactViewerProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const ext = getExtension(artifact.filename);
|
||||
const isMarkdown = ext === "md" || ext === "markdown" || artifact.language?.toLowerCase() === "markdown";
|
||||
const language = getLanguage(artifact.filename, artifact.language);
|
||||
const previewId = useMemo(() => `artifact-preview-${artifact.filename.replace(/[^a-zA-Z0-9_-]+/g, "-")}`, [artifact.filename]);
|
||||
|
||||
useEffect(() => {
|
||||
void ensureHljs();
|
||||
ensureCodeHljs();
|
||||
}, []);
|
||||
|
||||
const previewHtml = useMemo(() => {
|
||||
if (isMarkdown) return renderMarkdown(artifact.content);
|
||||
|
||||
let highlighted = artifact.content;
|
||||
try {
|
||||
highlighted = language !== "plaintext" && hljs.getLanguage(language)
|
||||
? hljs.highlight(artifact.content, { language }).value
|
||||
: hljs.highlightAuto(artifact.content).value;
|
||||
} catch {
|
||||
highlighted = artifact.content.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
return `<pre class="hljs rounded-2xl border border-zinc-800 bg-zinc-950 p-4 text-sm leading-7 text-zinc-200 overflow-auto"><code class="language-${language}">${highlighted}</code></pre>`;
|
||||
}, [artifact.content, isMarkdown, language]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(artifact.content);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
return;
|
||||
}
|
||||
downloadFile(artifact.filename, artifact.content, artifact.language ?? language);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
|
||||
<header className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-4 py-4 sm:px-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<FileGlyph filename={artifact.filename} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-sm font-semibold text-zinc-50" title={artifact.filename}>
|
||||
{artifact.filename}
|
||||
</h2>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.22em] text-zinc-400">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">
|
||||
Preview, copy, or download the generated artifact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M9 9h10v10H9z" strokeLinejoin="round" />
|
||||
<path d="M5 15V5h10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/80 px-3 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M12 4v10" strokeLinecap="round" />
|
||||
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 20h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={previewId}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="inline-flex items-center rounded-xl border border-zinc-700 bg-zinc-900/80 p-2 text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-4 w-4 transition-transform duration-200 ${expanded ? "rotate-180" : "rotate-0"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{expanded ? (
|
||||
<div
|
||||
id={previewId}
|
||||
className="max-h-[70vh] overflow-auto p-4 sm:p-5"
|
||||
>
|
||||
{isMarkdown ? (
|
||||
<div
|
||||
className="max-w-none text-sm leading-7 text-zinc-300 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-zinc-50 [&_h2]:mb-3 [&_h2]:mt-7 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-zinc-50 [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-zinc-100 [&_h4]:mt-5 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-zinc-100 [&_p]:my-3 [&_p]:text-zinc-300 [&_a]:text-sky-400 [&_a]:underline [&_a:hover]:text-sky-300 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_li]:text-zinc-300 [&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_hr]:my-6 [&_hr]:border-zinc-800 [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-zinc-800 [&_th]:border [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-zinc-100 [&_td]:border [&_td]:border-zinc-800 [&_td]:px-3 [&_td]:py-2 [&_td]:text-zinc-300 [&_code:not(.hljs)]:rounded [&_code:not(.hljs)]:border [&_code:not(.hljs)]:border-zinc-800 [&_code:not(.hljs)]:bg-zinc-900 [&_code:not(.hljs)]:px-1.5 [&_code:not(.hljs)]:py-0.5 [&_code:not(.hljs)]:font-mono [&_code:not(.hljs)]:text-[0.85em] [&_.code-block]:my-4 [&_.code-block]:overflow-hidden [&_.code-block]:rounded-2xl [&_.code-block]:border [&_.code-block]:border-zinc-800 [&_.code-block]:bg-zinc-950 [&_.code-header]:flex [&_.code-header]:items-center [&_.code-header]:justify-between [&_.code-header]:border-b [&_.code-header]:border-zinc-800 [&_.code-header]:bg-zinc-900/80 [&_.code-header]:px-3 [&_.code-header]:py-2 [&_.code-lang]:font-mono [&_.code-lang]:text-[11px] [&_.code-lang]:uppercase [&_.code-lang]:tracking-[0.22em] [&_.code-lang]:text-zinc-500 [&_.copy-code-btn]:rounded-lg [&_.copy-code-btn]:border [&_.copy-code-btn]:border-zinc-700 [&_.copy-code-btn]:bg-zinc-900 [&_.copy-code-btn]:px-2 [&_.copy-code-btn]:py-1 [&_.copy-code-btn]:text-[11px] [&_.copy-code-btn]:font-medium [&_.copy-code-btn]:uppercase [&_.copy-code-btn]:tracking-[0.18em] [&_.copy-code-btn]:text-zinc-200 [&_.copy-code-btn]:transition-colors [&_.copy-code-btn:hover]:border-zinc-600 [&_.copy-code-btn:hover]:bg-zinc-800"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-zinc-800/70 px-5 py-4 text-sm text-zinc-500">
|
||||
Preview collapsed.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import hljs from "highlight.js";
|
||||
import { marked } from "marked";
|
||||
import { Bot } from "lucide-react";
|
||||
import "highlight.js/styles/github-dark-dimmed.css";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isAgentic?: boolean;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
const markdownChrome = [
|
||||
"[&_p]:m-0",
|
||||
"[&_p+p]:mt-3",
|
||||
"[&_h1]:mt-4 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:tracking-tight",
|
||||
"[&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:tracking-tight",
|
||||
"[&_h3]:mt-3 [&_h3]:text-base [&_h3]:font-semibold",
|
||||
"[&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
|
||||
"[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5",
|
||||
"[&_li>p]:m-0",
|
||||
"[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-700 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400",
|
||||
"[&_a]:text-cyan-300 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-cyan-300/40 hover:[&_a]:text-cyan-200",
|
||||
"[&_strong]:font-semibold [&_strong]:text-zinc-50",
|
||||
"[&_em]:italic",
|
||||
"[&_hr]:my-4 [&_hr]:border-zinc-800",
|
||||
"[&_table]:my-4 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-xl [&_table]:border [&_table]:border-zinc-800",
|
||||
"[&_th]:border-b [&_th]:border-zinc-800 [&_th]:bg-zinc-900 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left",
|
||||
"[&_td]:border-b [&_td]:border-zinc-900 [&_td]:px-3 [&_td]:py-2",
|
||||
"[&_img]:my-4 [&_img]:rounded-xl [&_img]:border [&_img]:border-zinc-800",
|
||||
"[&_code:not(pre_code)]:rounded-md [&_code:not(pre_code)]:border [&_code:not(pre_code)]:border-white/5 [&_code:not(pre_code)]:bg-white/5 [&_code:not(pre_code)]:px-1.5 [&_code:not(pre_code)]:py-0.5 [&_code:not(pre_code)]:text-[0.92em]",
|
||||
].join(" ");
|
||||
|
||||
function formatTimestamp(timestamp?: Date) {
|
||||
if (!timestamp) return null;
|
||||
return new Intl.DateTimeFormat([], { hour: "2-digit", minute: "2-digit" }).format(timestamp);
|
||||
}
|
||||
|
||||
function safeLang(lang?: string) {
|
||||
if (!lang) return "plaintext";
|
||||
const normalized = lang.toLowerCase().trim();
|
||||
return /^[a-z0-9_+-]+$/.test(normalized) ? normalized : "plaintext";
|
||||
}
|
||||
|
||||
function highlightCode(code: string, lang?: string) {
|
||||
const language = safeLang(lang);
|
||||
|
||||
if (language !== "plaintext" && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(code, { language }).value;
|
||||
}
|
||||
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
|
||||
function buildMarkdown(content: string) {
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
renderer.code = (code, infostring) => {
|
||||
const lang = infostring?.split(/\s+/)[0];
|
||||
const highlighted = highlightCode(code, lang || undefined);
|
||||
const encodedCode = encodeURIComponent(code);
|
||||
const language = safeLang(lang || undefined);
|
||||
|
||||
return `
|
||||
<div class="group relative my-4 overflow-hidden rounded-2xl border border-zinc-800 bg-[#111111] shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
|
||||
<button
|
||||
type="button"
|
||||
data-copy-code="true"
|
||||
data-code="${encodedCode}"
|
||||
class="absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-zinc-950/80 px-2.5 py-1 text-[11px] font-medium text-zinc-300 backdrop-blur transition hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label="Copy code"
|
||||
title="Copy code"
|
||||
>
|
||||
<span class="copy-label">Copy</span>
|
||||
</button>
|
||||
<div class="overflow-x-auto">
|
||||
<pre class="m-0 bg-transparent p-4 pt-12 text-sm leading-6 text-zinc-100"><code class="hljs language-${language}">${highlighted}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const rawHtml = marked.parse(content ?? "", {
|
||||
renderer,
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
}) as string;
|
||||
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ADD_ATTR: ["data-copy-code", "data-code", "aria-label", "title"],
|
||||
});
|
||||
}
|
||||
|
||||
export default function MessageBubble({ role, content, isAgentic, timestamp }: MessageBubbleProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsStreaming(true);
|
||||
const timer = window.setTimeout(() => setIsStreaming(false), 450);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [content]);
|
||||
|
||||
const renderedContent = useMemo(() => buildMarkdown(content), [content]);
|
||||
const timeLabel = formatTimestamp(timestamp);
|
||||
|
||||
const onContentClick = async (event: MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest<HTMLButtonElement>("[data-copy-code]");
|
||||
|
||||
if (!button) return;
|
||||
|
||||
const code = decodeURIComponent(button.getAttribute("data-code") ?? "");
|
||||
await navigator.clipboard.writeText(code);
|
||||
|
||||
const label = button.querySelector<HTMLElement>(".copy-label");
|
||||
const icon = button.querySelector<HTMLElement>("svg");
|
||||
const original = label?.textContent ?? "Copy";
|
||||
|
||||
if (label) label.textContent = "Copied";
|
||||
if (icon) icon.style.display = "none";
|
||||
setCopied(true);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (label) label.textContent = original;
|
||||
if (icon) icon.style.display = "inline-flex";
|
||||
setCopied(false);
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
const outerAlign = role === "user" ? "justify-end" : "justify-start";
|
||||
const bubbleTone = role === "user" ? "bg-[#1E3A5F] text-slate-50" : "bg-[#1E1E1E] text-zinc-100";
|
||||
const widthTone = role === "user" ? "max-w-[84%]" : "max-w-[86%]";
|
||||
|
||||
return (
|
||||
<div className={`flex w-full ${outerAlign}`}>
|
||||
<article
|
||||
className={`${widthTone} rounded-2xl border border-white/5 px-4 py-3 shadow-[0_18px_50px_rgba(0,0,0,0.35)] ${bubbleTone}`}
|
||||
>
|
||||
{role === "assistant" ? (
|
||||
<header className="mb-3 flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-zinc-950/80 text-zinc-100 shadow-inner shadow-black/30">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-zinc-50">Forge</h3>
|
||||
<span className="rounded-full border border-cyan-400/20 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-cyan-200">
|
||||
Forge
|
||||
</span>
|
||||
{isAgentic ? (
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-emerald-200">
|
||||
Agentic
|
||||
</span>
|
||||
) : null}
|
||||
{timeLabel ? <span className="text-[11px] text-zinc-500">{timeLabel}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
onClick={onContentClick}
|
||||
className={`${markdownChrome} text-[14px] leading-6 ${role === "user" ? "[&_a]:text-sky-200 [&_strong]:text-white" : "[&_a]:text-cyan-300 [&_strong]:text-zinc-50"}`}
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-zinc-400">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-zinc-400/80" />
|
||||
<span>streaming</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{role === "user" && timeLabel ? (
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-slate-300/70">
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{copied ? <span className="sr-only">Code copied</span> : null}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Check, Circle, ChevronDown, Loader2, X } from "lucide-react";
|
||||
|
||||
type TaskStatus = "pending" | "running" | "completed" | "error";
|
||||
|
||||
export interface TaskListProps {
|
||||
steps: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
sub_steps: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
}>;
|
||||
}>;
|
||||
activeStepId?: number;
|
||||
}
|
||||
|
||||
const statusStyles: Record<TaskStatus, { ring: string; text: string; badge: string }> = {
|
||||
pending: {
|
||||
ring: "border-zinc-700 bg-zinc-900/70 text-zinc-500",
|
||||
text: "text-zinc-400",
|
||||
badge: "border-zinc-800 bg-zinc-900 text-zinc-400",
|
||||
},
|
||||
running: {
|
||||
ring: "border-sky-500/30 bg-sky-500/10 text-sky-400",
|
||||
text: "text-sky-300",
|
||||
badge: "border-sky-500/25 bg-sky-500/10 text-sky-300",
|
||||
},
|
||||
completed: {
|
||||
ring: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
|
||||
text: "text-emerald-300",
|
||||
badge: "border-emerald-500/25 bg-emerald-500/10 text-emerald-300",
|
||||
},
|
||||
error: {
|
||||
ring: "border-rose-500/30 bg-rose-500/10 text-rose-400",
|
||||
text: "text-rose-300",
|
||||
badge: "border-rose-500/25 bg-rose-500/10 text-rose-300",
|
||||
},
|
||||
};
|
||||
|
||||
function StatusGlyph({ status }: { status: TaskStatus }) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />;
|
||||
case "completed":
|
||||
return <Check className="h-4 w-4" aria-hidden="true" />;
|
||||
case "error":
|
||||
return <X className="h-4 w-4" aria-hidden="true" />;
|
||||
default:
|
||||
return <Circle className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
}
|
||||
|
||||
function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||
const palette = statusStyles[status];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full border ${palette.ring} shadow-sm shadow-black/20`}
|
||||
animate={status === "completed" ? { scale: [0.92, 1.08, 1] } : { scale: 1 }}
|
||||
transition={{ duration: 0.32, ease: "easeOut" }}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={status}
|
||||
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={palette.text}
|
||||
>
|
||||
<StatusGlyph status={status} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{status === "completed" ? <span className="absolute inset-0 rounded-full bg-emerald-400/10 blur-[1px]" aria-hidden="true" /> : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
step,
|
||||
isActive,
|
||||
}: {
|
||||
step: TaskListProps["steps"][number];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(isActive || step.status === "running" || step.status === "completed");
|
||||
const hasSubSteps = step.sub_steps.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive || step.status === "running") setExpanded(true);
|
||||
}, [isActive, step.status]);
|
||||
|
||||
return (
|
||||
<motion.li layout className="relative pl-10">
|
||||
<div className="absolute left-4 top-0 bottom-0 w-px bg-zinc-800/90" aria-hidden="true" />
|
||||
<div className="absolute left-[13px] top-5 h-2.5 w-2.5 rounded-full border border-zinc-700 bg-zinc-950" aria-hidden="true" />
|
||||
|
||||
<motion.article
|
||||
layout
|
||||
className={`rounded-2xl border bg-zinc-950/70 px-4 py-4 shadow-[0_0_0_1px_rgba(24,24,27,0.35)] transition-colors duration-300 ${
|
||||
isActive ? "border-zinc-700 bg-zinc-900/85" : "border-zinc-800/80"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<TaskStatusIcon status={step.status} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-400">
|
||||
Step {step.id}
|
||||
</span>
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
|
||||
</div>
|
||||
|
||||
{hasSubSteps ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Collapse task details" : "Expand task details"}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${expanded ? "rotate-180" : "rotate-0"}`} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{hasSubSteps && expanded ? (
|
||||
<motion.div
|
||||
key="substeps"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-l border-zinc-800/90 pl-4">
|
||||
{step.sub_steps.map((subStep) => (
|
||||
<motion.div
|
||||
key={subStep.id}
|
||||
layout
|
||||
className="flex items-start gap-3 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className={`mt-0.5 rounded-full p-1 ${statusStyles[subStep.status].badge}`}>
|
||||
<StatusGlyph status={subStep.status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
|
||||
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] ${statusStyles[subStep.status].badge}`}>
|
||||
{subStep.status}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskList({ steps, activeStepId }: TaskListProps) {
|
||||
const activeIds = useMemo(() => new Set([activeStepId]), [activeStepId]);
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-zinc-800 bg-zinc-950 text-zinc-100 shadow-2xl shadow-black/20">
|
||||
<div className="border-b border-zinc-800 px-5 py-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.24em] text-zinc-500">Task list</div>
|
||||
<h2 className="mt-1 text-lg font-semibold text-zinc-50">Thinking steps</h2>
|
||||
</div>
|
||||
|
||||
<motion.ul layout className="space-y-3 px-4 py-4">
|
||||
{steps.map((step) => (
|
||||
<TaskRow key={step.id} step={step} isActive={activeIds.has(step.id)} />
|
||||
))}
|
||||
</motion.ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
type StepStatus = "pending" | "running" | "completed" | "error";
|
||||
|
||||
interface ThinkingCardProps {
|
||||
title: string;
|
||||
overview: string;
|
||||
steps: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: StepStatus;
|
||||
sub_steps: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
status: StepStatus;
|
||||
}>;
|
||||
}>;
|
||||
currentStep: number;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
function statusIcon(status: StepStatus) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Icon name="Loader2" size={16} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
|
||||
case "completed":
|
||||
return <Icon name="CheckCircle2" size={16} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
|
||||
case "error":
|
||||
return <Icon name="XCircle" size={16} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
|
||||
default:
|
||||
return <Icon name="Circle" size={16} strokeWidth={2} className="text-zinc-500" aria-hidden="true" />;
|
||||
}
|
||||
}
|
||||
|
||||
function miniIcon(status: StepStatus) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Icon name="Loader2" size={12} strokeWidth={2} className="animate-spin text-amber-400" aria-hidden="true" />;
|
||||
case "completed":
|
||||
return <Icon name="CheckCircle2" size={12} strokeWidth={2} className="text-emerald-400" aria-hidden="true" />;
|
||||
case "error":
|
||||
return <Icon name="XCircle" size={12} strokeWidth={2} className="text-rose-400" aria-hidden="true" />;
|
||||
default:
|
||||
return <span className="mt-0.5 h-2 w-2 rounded-full border border-zinc-600 bg-zinc-700" aria-hidden="true" />;
|
||||
}
|
||||
}
|
||||
|
||||
function tone(status: StepStatus) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "border-amber-500/20 bg-amber-500/10 text-amber-300";
|
||||
case "completed":
|
||||
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-300";
|
||||
case "error":
|
||||
return "border-rose-500/20 bg-rose-500/10 text-rose-300";
|
||||
default:
|
||||
return "border-zinc-800 bg-zinc-900 text-zinc-400";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ThinkingCard({ title, overview, steps, currentStep, isComplete }: ThinkingCardProps) {
|
||||
const completedCount = steps.filter((step) => step.status === "completed").length;
|
||||
const progress = isComplete || steps.length === 0 ? 100 : Math.round((completedCount / steps.length) * 100);
|
||||
const activeLabel = isComplete ? "Plano de Execução" : "Thinking...";
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-3xl border border-zinc-800/80 bg-zinc-950 text-zinc-100 shadow-[0_24px_80px_-36px_rgba(0,0,0,0.9)]">
|
||||
<div className="border-b border-zinc-800/80 bg-gradient-to-b from-zinc-900/80 to-zinc-950 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-zinc-700 bg-zinc-900 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.2em] text-zinc-300">
|
||||
{activeLabel}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.22em] text-zinc-500">Agentic mode</span>
|
||||
</div>
|
||||
<h2 className="mt-2 truncate text-lg font-semibold text-zinc-50">{title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-400">{overview}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/80 px-3 py-2 text-right">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Progress</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-50">{progress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-zinc-500 via-zinc-300 to-emerald-400 transition-all duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
<span>{steps.length} steps</span>
|
||||
<span>{isComplete ? "Completed" : `Step ${currentStep}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-5">
|
||||
<ul className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep || step.status === "running";
|
||||
const isDone = step.status === "completed";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`relative rounded-2xl border px-4 py-4 transition-all duration-300 ${
|
||||
isActive ? "border-zinc-700 bg-zinc-900/80 shadow-[0_0_0_1px_rgba(63,63,70,0.35)]" : "border-zinc-800 bg-zinc-950/70"
|
||||
} ${isDone ? "opacity-95" : ""}`}
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
>
|
||||
{index !== steps.length - 1 ? <div className="absolute left-[26px] top-12 bottom-0 w-px bg-zinc-800" aria-hidden="true" /> : null}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border ${tone(step.status)}`}>
|
||||
{statusIcon(step.status)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 text-xs font-medium text-zinc-300">
|
||||
Step {step.id}
|
||||
</span>
|
||||
<h3 className="truncate text-sm font-semibold text-zinc-100">{step.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-400">{step.description}</p>
|
||||
</div>
|
||||
|
||||
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.18em] ${tone(step.status)}`}>
|
||||
{step.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{step.sub_steps.length > 0 ? (
|
||||
<div className="mt-4 space-y-2 border-l border-zinc-800 pl-4">
|
||||
{step.sub_steps.map((subStep) => (
|
||||
<div key={subStep.id} className="flex items-start gap-2 rounded-xl border border-zinc-900 bg-zinc-950/60 px-3 py-2">
|
||||
<div className="mt-0.5">{miniIcon(subStep.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-zinc-500">{subStep.id}</div>
|
||||
<div className="text-sm leading-6 text-zinc-200">{subStep.description}</div>
|
||||
</div>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.16em] ${tone(subStep.status)}`}>
|
||||
{subStep.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as MessageBubble } from './MessageBubble';
|
||||
export { default as ThinkingCard } from './ThinkingCard';
|
||||
export { default as TaskList } from './TaskList';
|
||||
export { default as ArtifactViewer } from './ArtifactViewer';
|
||||
@@ -13,52 +13,32 @@ export default function AgenticToggle({ isActive, onToggle, disabled = false }:
|
||||
aria-pressed={isActive}
|
||||
aria-label={isActive ? "Modo agentic ativo" : "Modo agentic desativado"}
|
||||
title="Alterna o modo agentic: o assistente pode planejar e executar tarefas com mais autonomia."
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "5px 10px",
|
||||
borderRadius: "var(--radius)",
|
||||
border: isActive
|
||||
? "1px solid rgba(72,166,255,0.35)"
|
||||
: "1px solid var(--border2)",
|
||||
background: isActive
|
||||
? "rgba(72,166,255,0.08)"
|
||||
: "var(--bg3)",
|
||||
color: isActive ? "var(--accent2)" : "var(--text2)",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 500,
|
||||
fontFamily: "inherit",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: "all 0.15s ease",
|
||||
whiteSpace: "nowrap",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
className={[
|
||||
"inline-flex h-9 items-center gap-2 rounded-2xl border px-3.5 text-[13px] font-medium transition-all",
|
||||
"backdrop-blur-sm whitespace-nowrap",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer hover:border-white/20 hover:bg-white/[0.06] hover:text-white/90",
|
||||
isActive
|
||||
? "border-sky-400/30 bg-sky-500/10 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.06)]"
|
||||
: "border-white/10 bg-white/[0.04] text-white/70",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
background: isActive ? "var(--accent)" : "var(--text3)",
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive ? "0 0 6px rgba(72,166,255,0.6)" : "none",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
className={[
|
||||
"h-2 w-2 rounded-full transition-all",
|
||||
isActive ? "bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]" : "bg-white/30",
|
||||
].join(" ")}
|
||||
/>
|
||||
Agentic
|
||||
<span>Agentic</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 600,
|
||||
color: isActive ? "var(--accent)" : "var(--text3)",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
className={[
|
||||
"text-[11px] font-semibold uppercase tracking-[0.14em]",
|
||||
isActive ? "text-sky-300" : "text-white/35",
|
||||
].join(" ")}
|
||||
>
|
||||
{isActive ? "ON" : "OFF"}
|
||||
{isActive ? "On" : "Off"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Database, Pencil, Trash } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import type { DB } from "../../lib/db";
|
||||
import type { UserMemory } from "../../lib/types";
|
||||
|
||||
interface MemoryModalProps {
|
||||
db: DB;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MemoryModal({ db, open, onClose }: MemoryModalProps) {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [, forceRender] = useState({});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const memories = db.query<UserMemory>(
|
||||
"SELECT id, key, value, confidence, created_at, updated_at FROM user_memories ORDER BY updated_at DESC",
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
if (!window.confirm("Clear all saved memories?")) return;
|
||||
db.run("DELETE FROM user_memories");
|
||||
forceRender({});
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (!window.confirm("Delete this memory?")) return;
|
||||
db.run("DELETE FROM user_memories WHERE id = ?", [id]);
|
||||
forceRender({});
|
||||
};
|
||||
|
||||
const handleEditStart = (memory: UserMemory) => {
|
||||
setEditingId(memory.id);
|
||||
setEditValue(memory.value);
|
||||
};
|
||||
|
||||
const handleEditSave = (id: number) => {
|
||||
if (!editValue.trim()) return;
|
||||
db.run(
|
||||
"UPDATE user_memories SET value = ?, updated_at = ? WHERE id = ?",
|
||||
[editValue.trim(), Math.floor(Date.now() / 1000), id],
|
||||
);
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
forceRender({});
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/55 backdrop-blur-sm px-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl max-h-[78vh] overflow-hidden rounded-2xl border border-white/10 bg-[#1b1b1f] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4 border-b border-white/8">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-white/92 text-sm font-semibold">
|
||||
<Database size={16} weight="duotone" className="text-blue-300" />
|
||||
<span>Saved memories</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] text-white/42">
|
||||
{memories.length} {memories.length === 1 ? "memory" : "memories"} saved
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{memories.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="px-3 py-1.5 rounded-lg border border-red-400/18 bg-red-500/8 text-[12px] font-medium text-red-300 hover:bg-red-500/14 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white/85 hover:bg-white/8 transition-colors"
|
||||
aria-label="Close memories"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(78vh-72px)] p-3 space-y-2">
|
||||
{memories.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-[13px] text-white/42">
|
||||
No memories saved yet. Memories are extracted automatically from your conversations.
|
||||
</div>
|
||||
) : (
|
||||
memories.map((memory) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className="rounded-xl border border-white/8 bg-white/[0.03] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] uppercase tracking-[0.12em] text-white/30">
|
||||
{memory.key}
|
||||
</p>
|
||||
{editingId === memory.id ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-[13px] text-white/90 placeholder:text-white/30 focus:outline-none focus:border-blue-400/50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEditSave(memory.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-blue-500/20 text-blue-300 text-[12px] font-medium hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/5 text-white/50 text-[12px] font-medium hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-white/82 leading-relaxed break-words">
|
||||
{memory.value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="px-2 py-1 rounded-full border border-white/8 bg-white/[0.04] text-[10.5px] text-white/45 tabular-nums">
|
||||
{(memory.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
{editingId !== memory.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditStart(memory)}
|
||||
className="p-1.5 rounded-lg text-white/30 hover:text-blue-300 hover:bg-blue-500/10 transition-colors"
|
||||
aria-label="Edit memory"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(memory.id)}
|
||||
className="p-1.5 rounded-lg text-white/30 hover:text-red-300 hover:bg-red-500/10 transition-colors"
|
||||
aria-label="Delete memory"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI Configuration
|
||||
*
|
||||
* Central configuration for the inference engine.
|
||||
* These are the ONLY parameters we set — everything else
|
||||
* uses the model's built-in defaults.
|
||||
*
|
||||
* Philosophy: less configuration → fewer loops, fewer artifacts.
|
||||
*/
|
||||
|
||||
// Maximum context window sent to the model (tokens)
|
||||
export const AI_CONTEXT_LENGTH = 32000;
|
||||
|
||||
// How many messages from history to include in each request
|
||||
export const AI_MAX_HISTORY_MESSAGES = 20;
|
||||
|
||||
// Whether to use SSE streaming by default
|
||||
export const AI_STREAM_DEFAULT = true;
|
||||
|
||||
// ── DO NOT ADD SYSTEM PROMPTS, STOP SEQUENCES, OR RESPONSE FORMAT INSTRUCTIONS HERE ──
|
||||
// ── Any modification to how the AI responds should be done in prompts/system.ts ──
|
||||
// ── and explicitly opted-in — NOT applied globally. ──
|
||||
@@ -1,801 +0,0 @@
|
||||
/* ── Shell ── */
|
||||
.sp-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left Navigation ── */
|
||||
.sp-nav {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: var(--manus-surface);
|
||||
border-right: 1px solid var(--manus-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-nav-header {
|
||||
padding: 24px 16px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-nav-brand {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-nav-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sp-nav-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sp-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.sp-nav-item:hover {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
}
|
||||
|
||||
.sp-nav-item--active {
|
||||
background: var(--manus-elevated);
|
||||
color: var(--manus-tx);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Runtime dot in nav */
|
||||
.sp-nav-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-nav-status-label {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.sp-nav-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.sp-back-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Status dot ── */
|
||||
.sp-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-dot--inline {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: currentColor;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
}
|
||||
|
||||
.sp-dot--success { background: var(--success); box-shadow: 0 0 6px rgba(48,209,88,0.55); }
|
||||
.sp-dot--warn { background: var(--warning); animation: sp-pulse 1.2s ease-in-out infinite; }
|
||||
.sp-dot--danger { background: var(--danger); }
|
||||
.sp-dot--idle { background: var(--text3); }
|
||||
|
||||
@keyframes sp-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Main area (right side of sidebar) ── */
|
||||
.settings-main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
background: var(--bg-main, #0B0E14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-scroll-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 120px 32px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.settings-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sp-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sp-section-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--manus-tx);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sp-section-desc {
|
||||
font-size: 14px;
|
||||
color: var(--manus-tx2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Status pill ── */
|
||||
.sp-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-status-pill--success {
|
||||
background: rgba(48,209,88,0.08);
|
||||
color: var(--success);
|
||||
border-color: rgba(48,209,88,0.2);
|
||||
}
|
||||
|
||||
.sp-status-pill--warn {
|
||||
background: rgba(255,214,10,0.08);
|
||||
color: var(--warning);
|
||||
border-color: rgba(255,214,10,0.2);
|
||||
}
|
||||
|
||||
.sp-status-pill--danger {
|
||||
background: rgba(255,69,58,0.08);
|
||||
color: var(--danger);
|
||||
border-color: rgba(255,69,58,0.2);
|
||||
}
|
||||
|
||||
/* ── Card (continuous, macOS/Linear style) ── */
|
||||
.sp-card {
|
||||
background: var(--manus-elevated);
|
||||
border: 1px solid var(--manus-hover);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.sp-card-row--full {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sp-card-row-label {
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.sp-card-row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-card-row-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sp-card-row-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sp-card-row-control--wide {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sp-card-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
/* ── Active model ── */
|
||||
.sp-model-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sp-model-path {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sp-pending {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: rgba(72,166,255,0.07);
|
||||
border: 1px solid rgba(72,166,255,0.18);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.sp-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Folder path ── */
|
||||
.sp-folder-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sp-folder-path {
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
background: var(--bg3);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Model list ── */
|
||||
.sp-model-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sp-model-row:last-child { border-bottom: none; }
|
||||
|
||||
.sp-model-row:hover:not(:disabled) { background: var(--bg4); }
|
||||
|
||||
.sp-model-row--selected {
|
||||
background: rgba(72,166,255,0.07);
|
||||
}
|
||||
|
||||
.sp-model-row--disabled { opacity: 0.45; pointer-events: none; }
|
||||
|
||||
.sp-model-row-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.sp-model-row-select:disabled { cursor: not-allowed; }
|
||||
|
||||
.sp-model-row-name { font-size: 13px; color: var(--text); }
|
||||
|
||||
.sp-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sp-remove-btn:hover {
|
||||
background: rgba(240,64,64,0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sp-scanning-row {
|
||||
padding: 12px 14px;
|
||||
color: var(--text3);
|
||||
font-size: 13px;
|
||||
background: var(--bg3);
|
||||
animation: sp-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Radio controls ── */
|
||||
.sp-radio {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border2);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.sp-radio--on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(72,166,255,0.18);
|
||||
}
|
||||
|
||||
/* ── Segmented control (macOS style) ── */
|
||||
.sp-segmented {
|
||||
display: inline-flex;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sp-segmented-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
font-family: inherit;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sp-segmented-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--bg4);
|
||||
}
|
||||
|
||||
.sp-segmented-btn--active {
|
||||
background: var(--bg4);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.sp-segmented-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* ── Context custom input ── */
|
||||
.sp-ctx-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Toggle switches (iOS style) ── */
|
||||
.sp-toggles {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sp-toggle input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sp-toggle-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sp-toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--text3);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.sp-toggle--active .sp-toggle-track {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.sp-toggle--active .sp-toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sp-toggle--active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sp-toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sp-toggle-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── GPU ── */
|
||||
.sp-gpu-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sp-gpu-name { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.sp-gpu-vram { font-size: 12px; color: var(--text3); font-family: monospace; }
|
||||
|
||||
/* ── Slider ── */
|
||||
.sp-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: var(--bg5);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sp-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 5px rgba(72,166,255,0.4);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.sp-slider::-webkit-slider-thumb:hover {
|
||||
box-shadow: 0 0 0 4px rgba(72,166,255,0.2);
|
||||
}
|
||||
|
||||
.sp-slider-presets {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Field label ── */
|
||||
.sp-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sp-field-label {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sp-field-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/* ── Inputs ── */
|
||||
.sp-input {
|
||||
background: var(--manus-surface);
|
||||
border: 1px solid var(--manus-elevated);
|
||||
border-radius: 8px;
|
||||
color: var(--manus-tx);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sp-input:hover {
|
||||
border-color: var(--manus-hover);
|
||||
}
|
||||
|
||||
.sp-input:focus {
|
||||
border-color: var(--manus-blue);
|
||||
background: var(--manus-elevated);
|
||||
box-shadow: 0 0 0 1px var(--manus-blue);
|
||||
}
|
||||
|
||||
.sp-input:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sp-input--sm {
|
||||
width: 120px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Hint text ── */
|
||||
.sp-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sp-hint code {
|
||||
font-family: "Cascadia Code", "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
background: var(--bg4);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.sp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sp-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.sp-btn--primary {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
.sp-btn--primary:hover:not(:disabled) { opacity: 0.85; }
|
||||
|
||||
.sp-btn--secondary {
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sp-btn--secondary:hover:not(:disabled) { background: var(--bg4); color: var(--text); }
|
||||
|
||||
.sp-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
border-color: transparent;
|
||||
}
|
||||
.sp-btn--ghost:hover:not(:disabled) { background: var(--bg3); color: var(--text); }
|
||||
|
||||
.sp-btn--danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: rgba(240,64,64,0.3);
|
||||
}
|
||||
.sp-btn--danger:hover:not(:disabled) { background: rgba(240,64,64,0.1); }
|
||||
|
||||
.sp-btn-xs {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
transition: color 0.12s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sp-btn-xs:hover { color: var(--accent); }
|
||||
|
||||
/* ── Floating action bar ── */
|
||||
.settings-action-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(11, 14, 20, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.action-bar-inner {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sp-error-inline {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
background: rgba(240,64,64,0.08);
|
||||
border: 1px solid rgba(240,64,64,0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Diagnostics ── */
|
||||
.sp-diag-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sp-diag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sp-diag-key { color: var(--text3); flex-shrink: 0; }
|
||||
.sp-diag-val { color: var(--text2); text-align: right; word-break: break-all; }
|
||||
|
||||
.sp-mono { font-family: "Cascadia Code", "JetBrains Mono", monospace; font-size: 11px; }
|
||||
.sp-accent { color: var(--accent); }
|
||||
.sp-danger { color: var(--danger); }
|
||||
|
||||
/* ── Utilities ── */
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.w-full { width: 100%; }
|
||||
.w-32 { width: 8rem; }
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -148,11 +148,11 @@ describe("SettingsPage Hugging Face downloads", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const fileRow = container.querySelector(".sp-hf-file");
|
||||
expect(fileRow).not.toBeNull();
|
||||
const downloadButton = container.querySelector('button[aria-label="Download quantized/phi-4-q4.gguf"]') as HTMLButtonElement;
|
||||
expect(downloadButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(fileRow?.querySelector('button[aria-label^="Download "]') as HTMLButtonElement).click();
|
||||
downloadButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "../../runtime/types";
|
||||
|
||||
interface Props {
|
||||
draftConfig: RuntimeConfig;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
snapshot: RuntimeSnapshot;
|
||||
busyAll: boolean;
|
||||
localError: string;
|
||||
}
|
||||
|
||||
export function AdvancedTab({
|
||||
draftConfig,
|
||||
updateDraft,
|
||||
snapshot,
|
||||
busyAll,
|
||||
localError,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<header className="mb-7 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-semibold text-neutral-100 tracking-tight mb-1">Advanced</h2>
|
||||
<p className="text-[13px] text-neutral-400 leading-relaxed">
|
||||
HuggingFace access and diagnostic information.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{localError && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2 leading-relaxed">
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
{/* HF Token */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">HuggingFace token</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Required for gated or private models</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
{draftConfig.hugging_face_token && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase tracking-wider bg-green-500/10 text-green-500 border border-green-500/20 shrink-0">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Token value</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Generate at huggingface.co/settings/tokens</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-[13px] bg-neutral-800 border border-neutral-700 rounded-md text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-600 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
type="password"
|
||||
value={draftConfig.hugging_face_token || ""}
|
||||
onChange={(e) => updateDraft({ hugging_face_token: e.target.value })}
|
||||
placeholder="hf_…"
|
||||
disabled={busyAll}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics */}
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex flex-col gap-3 p-4 px-5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Diagnostics</div>
|
||||
<dl className="flex flex-col gap-3 w-full">
|
||||
<div className="flex items-start justify-between gap-3 text-[13px]">
|
||||
<dt className="text-neutral-500 shrink-0">Runtime status</dt>
|
||||
<dd className="text-neutral-300 text-right break-all">{snapshot.status}</dd>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3 text-[13px]">
|
||||
<dt className="text-neutral-500 shrink-0">Binary path</dt>
|
||||
<dd className="text-[11px] font-mono text-neutral-300 text-right break-all">
|
||||
{snapshot.binary_path || "Not found"}
|
||||
</dd>
|
||||
</div>
|
||||
{snapshot.resolved_url && (
|
||||
<div className="flex items-start justify-between gap-3 text-[13px]">
|
||||
<dt className="text-neutral-500 shrink-0">Resolved endpoint</dt>
|
||||
<dd className="text-blue-400 text-right break-all">{snapshot.resolved_url}</dd>
|
||||
</div>
|
||||
)}
|
||||
{snapshot.last_error && (
|
||||
<div className="flex items-start justify-between gap-3 text-[13px]">
|
||||
<dt className="text-neutral-500 shrink-0">Last error</dt>
|
||||
<dd className="text-red-500 text-right break-all">{snapshot.last_error.message}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { RuntimeConfig } from "../../runtime/types";
|
||||
|
||||
interface Props {
|
||||
draftConfig: RuntimeConfig;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
busyAll: boolean;
|
||||
localError: string;
|
||||
}
|
||||
|
||||
export function ConnectionTab({
|
||||
draftConfig,
|
||||
updateDraft,
|
||||
busyAll,
|
||||
localError,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<header className="mb-7 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-semibold text-neutral-100 tracking-tight mb-1">Connection</h2>
|
||||
<p className="text-[13px] text-neutral-400 leading-relaxed">
|
||||
Use the built-in local inference engine or connect to a remote OpenAI-compatible server.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{localError && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2 leading-relaxed">
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
{/* Mode */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Mode</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">
|
||||
{draftConfig.connection_mode === "local"
|
||||
? "Runs the inference server locally on your machine."
|
||||
: "Forwards all requests to an external OpenAI-compatible endpoint."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<div className="inline-flex bg-neutral-800 border border-neutral-700 rounded-md p-0.5 gap-0.5">
|
||||
{(["local", "remote"] as const).map((mode) => {
|
||||
const active = draftConfig.connection_mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`px-3.5 py-1.5 rounded text-xs font-medium cursor-pointer bg-transparent border-none transition-colors whitespace-nowrap ${
|
||||
active
|
||||
? "bg-neutral-700 text-neutral-100 shadow-sm"
|
||||
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700/50"
|
||||
}`}
|
||||
onClick={() => updateDraft({ connection_mode: mode })}
|
||||
disabled={busyAll}
|
||||
>
|
||||
{mode === "local" ? "Local API" : "Remote API"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local settings */}
|
||||
{draftConfig.connection_mode === "local" && (
|
||||
<>
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Bind address</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Host and port for the local API</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-[13px] bg-neutral-800 border border-neutral-700 rounded-md text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-600 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
value={draftConfig.addr}
|
||||
onChange={(e) => updateDraft({ addr: e.target.value })}
|
||||
placeholder="127.0.0.1:8080"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1 leading-relaxed text-right">
|
||||
Available at{" "}
|
||||
<code className="font-mono text-[11px] text-neutral-300 bg-neutral-800 px-1.5 py-0.5 rounded">
|
||||
http://{draftConfig.addr || "127.0.0.1:8080"}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote settings */}
|
||||
{draftConfig.connection_mode === "remote" && (
|
||||
<>
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Server URL</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Base URL of the remote API</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-[13px] bg-neutral-800 border border-neutral-700 rounded-md text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-600 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
value={draftConfig.server_url}
|
||||
onChange={(e) => updateDraft({ server_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">API key</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Authentication key for the remote server</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-[13px] bg-neutral-800 border border-neutral-700 rounded-md text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-600 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
type="password"
|
||||
value={draftConfig.api_key}
|
||||
onChange={(e) => updateDraft({ api_key: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
disabled={busyAll}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
pickRuntimeModelFiles,
|
||||
pickRuntimeModelsDir,
|
||||
deleteModelFile,
|
||||
openModelFolder,
|
||||
} from "../../runtime/api";
|
||||
import {
|
||||
dedupeModels,
|
||||
pathName,
|
||||
toModelFile,
|
||||
} from "../../runtime/runtime-settings-form";
|
||||
import { useModelDownload } from "../../runtime/useModelDownload";
|
||||
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../../runtime/runtime-utils";
|
||||
import type {
|
||||
RuntimeConfig,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
} from "../../runtime/types";
|
||||
import { X, Trash2, Search, Info, Download, FolderOpen } from "lucide-react";
|
||||
import { useHuggingFaceSearch } from "../hooks/useHuggingFaceSearch";
|
||||
|
||||
interface Props {
|
||||
snapshot: RuntimeSnapshot;
|
||||
draftConfig: RuntimeConfig;
|
||||
draftDirty: boolean;
|
||||
busyAll: boolean;
|
||||
scannedModels: RuntimeModelFile[];
|
||||
isScanningModels: boolean;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
scanModels: (dir: string | null) => Promise<void>;
|
||||
handleModelSwap: (newModelPath: string) => Promise<void>;
|
||||
localError: string;
|
||||
setLocalError: (error: string) => void;
|
||||
successMessage: string;
|
||||
setSuccessMessage: (msg: string) => void;
|
||||
isSwapping: boolean;
|
||||
swappingMessage: string;
|
||||
onModelSwapped?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
function formatMiBAsGbMb(mib: number): string {
|
||||
if (!Number.isFinite(mib) || mib <= 0) return "0 MB";
|
||||
if (mib >= 1024) return `${(mib / 1024).toFixed(2)} GB`;
|
||||
return `${mib.toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function normalizeModelEntryName(value: string): string {
|
||||
return value.replace(/\.gguf$/i, "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function statusPillClass(snapshot: RuntimeSnapshot): string {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
if (tone === "success") return "bg-green-500/10 text-green-500 border-green-500/20";
|
||||
if (tone === "warning") return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
|
||||
if (tone === "danger") return "bg-red-500/10 text-red-500 border-red-500/20";
|
||||
return "bg-neutral-800 text-neutral-400 border-neutral-700";
|
||||
}
|
||||
|
||||
export function ModelTab({
|
||||
snapshot,
|
||||
draftConfig,
|
||||
draftDirty,
|
||||
busyAll,
|
||||
scannedModels,
|
||||
isScanningModels,
|
||||
updateDraft,
|
||||
scanModels,
|
||||
handleModelSwap,
|
||||
localError,
|
||||
setLocalError,
|
||||
successMessage,
|
||||
setSuccessMessage,
|
||||
isSwapping,
|
||||
swappingMessage,
|
||||
onModelSwapped,
|
||||
}: Props) {
|
||||
const didAutoScan = useRef(false);
|
||||
const isRunning = snapshot.process_running;
|
||||
const isStopping = snapshot.status === "stopping";
|
||||
|
||||
const {
|
||||
activeFile: activeDownloadKey,
|
||||
cancelDownload,
|
||||
didComplete,
|
||||
downloaded,
|
||||
downloadedFilePath,
|
||||
error: downloadError,
|
||||
isDownloading,
|
||||
progress,
|
||||
resetDidComplete,
|
||||
speed,
|
||||
startDownload,
|
||||
total,
|
||||
} = useModelDownload(draftConfig.hugging_face_token || "");
|
||||
|
||||
const {
|
||||
hfQuery,
|
||||
setHfQuery,
|
||||
hfResults,
|
||||
hfLoading,
|
||||
hfHasSearched,
|
||||
setHfHasSearched,
|
||||
error: hfError,
|
||||
handleHfSearch,
|
||||
} = useHuggingFaceSearch(draftConfig.hugging_face_token || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (hfError) setLocalError(hfError);
|
||||
}, [hfError, setLocalError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didAutoScan.current) return;
|
||||
const dir = draftConfig.models_dir?.trim();
|
||||
if (dir && scannedModels.length === 0 && !isScanningModels) {
|
||||
didAutoScan.current = true;
|
||||
void scanModels(dir);
|
||||
}
|
||||
}, [draftConfig.models_dir, scannedModels.length, isScanningModels, scanModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadError) {
|
||||
setLocalError(downloadError);
|
||||
}
|
||||
}, [downloadError, setLocalError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDownloading) {
|
||||
setSuccessMessage("");
|
||||
}
|
||||
}, [isDownloading, setSuccessMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didComplete) return;
|
||||
const downloadedPath = downloadedFilePath;
|
||||
const modelPath = downloadedPath;
|
||||
|
||||
if (modelPath) {
|
||||
console.log("[SettingsPage] Auto-selecionando modelo baixado:", modelPath);
|
||||
updateDraft({ model_path: modelPath });
|
||||
onModelSwapped?.(modelPath);
|
||||
setSuccessMessage(
|
||||
`Modelo baixado e selecionado com sucesso!\n${modelPath}`
|
||||
);
|
||||
}
|
||||
resetDidComplete();
|
||||
}, [didComplete, downloadedFilePath, updateDraft, onModelSwapped, setSuccessMessage, resetDidComplete]);
|
||||
|
||||
const handlePickDir = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const result = await pickRuntimeModelsDir();
|
||||
if (!result) return;
|
||||
updateDraft({ models_dir: result.dir });
|
||||
void scanModels(result.dir);
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
}
|
||||
}, [updateDraft, scanModels, setLocalError]);
|
||||
|
||||
const handlePickFiles = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const picked = await pickRuntimeModelFiles();
|
||||
if (picked.length === 0) return;
|
||||
const next = dedupeModels([
|
||||
...draftConfig.extra_model_paths.map(toModelFile),
|
||||
...picked,
|
||||
]);
|
||||
updateDraft({
|
||||
extra_model_paths: next.map((m) => m.path),
|
||||
model_path: draftConfig.model_path || next[0]?.path || null,
|
||||
});
|
||||
} catch (e) {
|
||||
setLocalError(String(e));
|
||||
}
|
||||
}, [draftConfig, updateDraft, setLocalError]);
|
||||
|
||||
const handleRemoveExtra = useCallback((path: string) => {
|
||||
const next = draftConfig.extra_model_paths.filter((p) => p !== path);
|
||||
updateDraft({
|
||||
extra_model_paths: next,
|
||||
model_path: draftConfig.model_path === path
|
||||
? (next[0] ?? scannedModels[0]?.path ?? null)
|
||||
: draftConfig.model_path,
|
||||
});
|
||||
}, [draftConfig, scannedModels, updateDraft]);
|
||||
|
||||
const handleDownload = useCallback(async (repo: string, filePath: string) => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await startDownload(repo, filePath);
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
}, [startDownload, setLocalError]);
|
||||
|
||||
const handleCancelDownload = useCallback(async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await cancelDownload();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
}, [cancelDownload, setLocalError]);
|
||||
|
||||
const handleOpenFolder = useCallback(async (filePath: string) => {
|
||||
try {
|
||||
await openModelFolder(filePath);
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
}, [setLocalError]);
|
||||
|
||||
const handleDeleteModel = useCallback(async (filePath: string) => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await deleteModelFile(filePath);
|
||||
void scanModels(draftConfig.models_dir ?? null);
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
}, [draftConfig.models_dir, scanModels, setLocalError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="mb-7 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-semibold text-neutral-100 tracking-tight mb-1">Model</h2>
|
||||
<p className="text-[13px] text-neutral-400 leading-relaxed">
|
||||
Select and hot-swap local GGUF models. Picking a model here applies it immediately.
|
||||
</p>
|
||||
</div>
|
||||
{isRunning && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase tracking-wider border shrink-0 ${statusPillClass(snapshot)}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current shadow-[0_0_6px_currentColor]" />
|
||||
{isStopping ? "Stopping local API" : getRuntimeStateLabel(snapshot)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{localError && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2 leading-relaxed">
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
{/* Active model */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Active model</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Currently loaded GGUF file</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
{snapshot.config.model_path ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-neutral-100 mb-0.5">{pathName(snapshot.config.model_path)}</div>
|
||||
<div className="text-[11px] text-neutral-400 font-mono break-all leading-relaxed">{snapshot.config.model_path}</div>
|
||||
{draftDirty && draftConfig.model_path !== snapshot.config.model_path && (
|
||||
<div className="text-xs text-blue-400 bg-blue-500/10 border border-blue-500/20 rounded-md px-2.5 py-1.5 mt-2">
|
||||
Draft → {pathName(draftConfig.model_path) || "None selected"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[13px] text-neutral-500 italic">Use the sections below to load one.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan folder */}
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Scan folder</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Browse and scan a directory for .gguf files</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-md text-[13px] font-medium bg-neutral-800 text-neutral-300 border border-neutral-700 hover:bg-neutral-700 hover:text-neutral-100 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => void handlePickDir()}
|
||||
disabled={busyAll}
|
||||
>
|
||||
Browse…
|
||||
</button>
|
||||
{draftConfig.models_dir && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-md text-[13px] font-medium bg-transparent text-neutral-400 border border-transparent hover:bg-neutral-800 hover:text-neutral-100 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => { void scanModels(draftConfig.models_dir!); }}
|
||||
disabled={busyAll || isScanningModels}
|
||||
>
|
||||
{isScanningModels ? "Scanning…" : "Rescan"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{draftConfig.models_dir && (
|
||||
<div className="text-xs text-neutral-300 font-mono bg-neutral-800 px-2.5 py-1.5 rounded-md border border-neutral-700 break-all mt-1.5">
|
||||
{draftConfig.models_dir}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model list from scan */}
|
||||
{(isScanningModels || scannedModels.length > 0) && (
|
||||
<>
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex flex-col items-stretch w-full gap-3 p-4 px-5">
|
||||
{isScanningModels && scannedModels.length === 0 && (
|
||||
<div className="px-3.5 py-3 text-neutral-400 text-[13px] bg-neutral-800 animate-pulse">Scanning for models…</div>
|
||||
)}
|
||||
<div className="border border-neutral-800 rounded-md overflow-hidden w-full">
|
||||
{scannedModels.map((model) => {
|
||||
const selected = draftConfig.model_path === model.path;
|
||||
return (
|
||||
<button
|
||||
key={model.path}
|
||||
type="button"
|
||||
className={`flex items-center gap-2.5 w-full px-3.5 py-2.5 bg-neutral-800 border-b border-neutral-800 last:border-b-0 cursor-pointer transition-colors text-left text-neutral-100 text-[13px] hover:bg-neutral-700 disabled:opacity-45 disabled:pointer-events-none ${
|
||||
selected ? "bg-blue-500/10" : ""
|
||||
}`}
|
||||
onClick={() => handleModelSwap(model.path)}
|
||||
disabled={busyAll}
|
||||
>
|
||||
<span className={`inline-block w-3.5 h-3.5 rounded-full border-2 shrink-0 transition-all ${
|
||||
selected ? "border-blue-500 bg-blue-500 shadow-[0_0_0_3px_rgba(75,134,255,0.18)]" : "border-neutral-600"
|
||||
}`} />
|
||||
<span className="flex-1 truncate text-[13px] text-neutral-100">{model.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Browse individual files */}
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Individual files</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Add specific .gguf files without scanning a folder</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-md text-[13px] font-medium bg-neutral-800 text-neutral-300 border border-neutral-700 hover:bg-neutral-700 hover:text-neutral-100 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => void handlePickFiles()}
|
||||
disabled={busyAll}
|
||||
>
|
||||
Pick files…
|
||||
</button>
|
||||
{draftConfig.extra_model_paths.length > 0 && (
|
||||
<div className="border border-neutral-800 rounded-md overflow-hidden w-full mt-3">
|
||||
{draftConfig.extra_model_paths.map((p) => {
|
||||
const selected = draftConfig.model_path === p;
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
className={`flex items-center gap-2.5 w-full px-3.5 py-2.5 bg-neutral-800 border-b border-neutral-800 last:border-b-0 transition-colors text-left text-neutral-100 text-[13px] ${
|
||||
selected ? "bg-blue-500/10" : ""
|
||||
} ${busyAll ? "opacity-45 pointer-events-none" : ""}`}
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2.5 flex-1 min-w-0 bg-transparent border-none cursor-pointer p-0 text-left"
|
||||
onClick={() => handleModelSwap(p)}
|
||||
disabled={busyAll}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={`inline-block w-3.5 h-3.5 rounded-full border-2 shrink-0 transition-all ${
|
||||
selected ? "border-blue-500 bg-blue-500 shadow-[0_0_0_3px_rgba(75,134,255,0.18)]" : "border-neutral-600"
|
||||
}`} />
|
||||
<span className="flex-1 truncate text-[13px] text-neutral-100">{pathName(p)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-transparent border-none text-neutral-500 cursor-pointer p-1 rounded shrink-0 flex items-center justify-center transition-colors hover:bg-red-500/10 hover:text-red-500"
|
||||
onClick={() => handleRemoveExtra(p)}
|
||||
disabled={busyAll}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace search */}
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
<div className="flex flex-col items-stretch w-full gap-3 p-4 px-5">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between gap-4 w-full">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Search HuggingFace</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">
|
||||
{!draftConfig.hugging_face_token && "Add HF token in Advanced for private models"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="group relative inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-neutral-800 bg-neutral-800 text-neutral-500 cursor-pointer transition-colors shrink-0 outline-none hover:text-neutral-100 hover:bg-neutral-700 focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Explain quantization levels"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
|
||||
<div className="pointer-events-none absolute right-0 top-[calc(100%+8px)] z-50 w-80 rounded-lg border border-neutral-800 bg-neutral-900 p-4 text-left shadow-lg backdrop-blur-md hidden group-hover:block group-focus-within:block">
|
||||
<div className="text-xs font-semibold text-neutral-100 mb-2">O que é Quantização?</div>
|
||||
<p className="text-[11px] leading-relaxed text-neutral-400 mb-3">
|
||||
É a compressão do modelo para que ele rode mais rápido e caiba na memória (RAM/VRAM) do seu PC.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-[11px] leading-relaxed text-neutral-400">
|
||||
<span>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full mr-1 shrink-0 bg-green-500 shadow-[0_0_6px_rgba(48,209,88,0.6)]" />
|
||||
<strong className="text-neutral-300">Máxima Qualidade (Pesado):</strong> Q8_0, Q6_K — Quase sem perda de inteligência.
|
||||
</span>
|
||||
<span>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full mr-1 shrink-0 bg-blue-500 shadow-[0_0_6px_rgba(75,134,255,0.6)]" />
|
||||
<strong className="text-neutral-300">Equilíbrio Ideal (Recomendado):</strong> Q4_K_M, Q5_K_M — Melhor custo-benefício.
|
||||
</span>
|
||||
<span>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full mr-1 shrink-0 bg-yellow-500 shadow-[0_0_6px_rgba(255,214,10,0.6)]" />
|
||||
<strong className="text-neutral-300">Alta Compressão (Leve):</strong> Q2_K, Q3_K_M — Roda em PCs fracos, mas o modelo fica mais "esquecido" ou impreciso.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full items-center">
|
||||
<input
|
||||
className="h-10 w-full min-w-0 rounded-md border border-neutral-800 bg-neutral-800 px-3.5 text-[13px] text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15"
|
||||
placeholder="Search for GGUF models…"
|
||||
value={hfQuery}
|
||||
onChange={(e) => {
|
||||
setHfQuery(e.target.value);
|
||||
setHfHasSearched(false);
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleHfSearch(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 shrink-0 px-4 rounded-md border border-blue-500/30 bg-blue-500/10 text-blue-400 text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap hover:bg-blue-500/20 hover:border-blue-500/50 disabled:opacity-45 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
onClick={() => void handleHfSearch()}
|
||||
disabled={hfLoading || !hfQuery.trim()}
|
||||
>
|
||||
<Search size={14} strokeWidth={2} />
|
||||
{hfLoading ? "…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="px-3.5 py-2.5 bg-green-500/10 border border-green-500/20 rounded-md flex items-start gap-2">
|
||||
<span className="text-green-500 shrink-0 mt-px">✓</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-green-400 font-medium leading-relaxed">
|
||||
{successMessage.split("\n")[0]}
|
||||
</p>
|
||||
<p className="text-[11px] text-green-400/60 font-mono mt-0.5 break-all leading-relaxed">
|
||||
{successMessage.split("\n")[1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hot-swap loading indicator */}
|
||||
{isSwapping && (
|
||||
<div className="px-3.5 py-2.5 bg-blue-500/10 border border-blue-500/20 rounded-md flex items-center gap-2.5 text-[13px] text-blue-400">
|
||||
<span className="w-3.5 h-3.5 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin shrink-0" />
|
||||
{swappingMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Hub: Local library (empty query) ── */}
|
||||
{!hfQuery.trim() && scannedModels.length > 0 && (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<p className="text-[11px] font-semibold text-neutral-500 uppercase tracking-widest px-0.5 mb-1">
|
||||
Modelos Locais ({scannedModels.length})
|
||||
</p>
|
||||
{scannedModels.map((model) => {
|
||||
const isSelected = draftConfig.model_path === model.path;
|
||||
const modelSize = model.size
|
||||
? `${(model.size / 1_073_741_824).toFixed(1)} GB`
|
||||
: "—";
|
||||
return (
|
||||
<div
|
||||
key={model.path}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-2.5 rounded-md border border-neutral-800 bg-neutral-800 transition-colors shrink-0 min-h-[44px] w-full hover:bg-neutral-700 ${
|
||||
isSelected ? "bg-blue-500/10 border-blue-500/25" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex-1 min-w-0 text-[13px] text-neutral-300 overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer ${
|
||||
isSelected ? "text-blue-400 font-medium" : ""
|
||||
}`}
|
||||
title={model.path}
|
||||
onClick={() => !isSwapping && !isDownloading && handleModelSwap(model.path)}
|
||||
>
|
||||
{pathName(model.path)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0 whitespace-nowrap">
|
||||
<span className="text-[11px] font-mono text-neutral-500 whitespace-nowrap">{modelSize}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenFolder(model.path)}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded-md border-none bg-transparent text-neutral-500 cursor-pointer transition-colors shrink-0 hover:bg-blue-500/10 hover:text-blue-400"
|
||||
title="Abrir pasta"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteModel(model.path)}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded-md border-none bg-transparent text-neutral-500 cursor-pointer transition-colors shrink-0 hover:bg-red-500/10 hover:text-red-500"
|
||||
title="Excluir modelo"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hfQuery.trim() && scannedModels.length === 0 && !isScanningModels && (
|
||||
<p className="text-[12px] text-neutral-500 leading-relaxed">Nenhum modelo local. Pesquise no HuggingFace ou configure a pasta de modelos.</p>
|
||||
)}
|
||||
|
||||
{/* ── Hub: HuggingFace search results ── */}
|
||||
{hfQuery.trim() && hfResults.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full max-h-[400px] overflow-y-auto pr-1">
|
||||
{hfResults.map((result) => {
|
||||
const localNames = new Set(
|
||||
scannedModels.map((m) => normalizeModelEntryName(pathName(m.path))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.repo_id}
|
||||
className="flex flex-col w-full bg-neutral-800 border border-neutral-800 rounded-lg overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="flex flex-col px-4 py-3 bg-black/20 border-b border-neutral-800 w-full">
|
||||
<div className="text-[13px] font-semibold text-blue-400 overflow-hidden text-ellipsis whitespace-nowrap">{result.repo_id}</div>
|
||||
<div className="text-[11px] text-neutral-500 overflow-hidden text-ellipsis whitespace-nowrap mt-0.5">{result.author || "Unknown author"}</div>
|
||||
</div>
|
||||
|
||||
{result.verified_files.length > 0 && (
|
||||
<div className="flex flex-col w-full">
|
||||
{result.verified_files.map((file) => {
|
||||
const downloadKey = `${result.repo_id}:${file.path}`;
|
||||
const isActiveDownload = activeDownloadKey === downloadKey;
|
||||
const fileName = file.path.replace(/\.gguf$/i, "");
|
||||
const fileSize = file.size
|
||||
? `${(file.size / 1_073_741_824).toFixed(1)} GB`
|
||||
: "—";
|
||||
const quantMatch = file.path.replace(/\.gguf$/i, "").split(/[-.]/).pop() ?? "gguf";
|
||||
const normalizedFileName = normalizeModelEntryName(fileName);
|
||||
const isLocalDuplicate = localNames.has(normalizedFileName);
|
||||
|
||||
const localModel = scannedModels.find(
|
||||
(m) => normalizeModelEntryName(pathName(m.path)) === normalizedFileName,
|
||||
);
|
||||
|
||||
let actionButtons: React.ReactNode;
|
||||
if (isActiveDownload && isDownloading) {
|
||||
actionButtons = (
|
||||
<div className="flex items-center gap-3 shrink-0 w-[200px]">
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0 w-full">
|
||||
<div className="flex items-center justify-between w-full text-[10px] font-mono text-neutral-500">
|
||||
<span>{Number.isFinite(speed) ? speed.toFixed(1) : "0.0"} MB/s</span>
|
||||
<span>{formatMiBAsGbMb(downloaded)} / {formatMiBAsGbMb(total)}</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-black/40 rounded-full overflow-hidden border border-neutral-800">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(0, Math.min(100, Number.isFinite(progress) ? progress : 0))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCancelDownload()}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded-md border-none bg-transparent text-neutral-500 cursor-pointer transition-colors shrink-0 hover:bg-red-500/10 hover:text-red-500"
|
||||
title="Cancelar Download"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (isLocalDuplicate) {
|
||||
actionButtons = (
|
||||
<div className="flex items-center gap-2 shrink-0 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => localModel && void handleOpenFolder(localModel.path)}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded-md border-none bg-transparent text-neutral-500 cursor-pointer transition-colors shrink-0 hover:bg-blue-500/10 hover:text-blue-400"
|
||||
title="Abrir pasta"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => localModel && void handleDeleteModel(localModel.path)}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded-md border-none bg-transparent text-neutral-500 cursor-pointer transition-colors shrink-0 hover:bg-red-500/10 hover:text-red-500"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
actionButtons = (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-md border border-neutral-800 bg-neutral-800 text-neutral-500 cursor-pointer transition-colors shrink-0 outline-none hover:bg-neutral-700 hover:text-blue-400 hover:border-blue-500/30 focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
title={`Download ${file.path}`}
|
||||
aria-label={`Download ${file.path}`}
|
||||
onClick={() => void handleDownload(result.repo_id, file.path)}
|
||||
disabled={isDownloading || isSwapping}
|
||||
>
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${result.repo_id}:${file.path}`}
|
||||
className="flex items-center justify-between gap-4 px-4 py-2.5 border-b border-neutral-800 transition-colors shrink-0 min-h-[44px] w-full last:border-b-0 hover:bg-white/5"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-[13px] text-neutral-400 overflow-hidden text-ellipsis whitespace-nowrap" title={file.path}>
|
||||
{fileName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0 whitespace-nowrap">
|
||||
<span className="text-[11px] font-mono text-neutral-500 whitespace-nowrap">{fileSize}</span>
|
||||
<span className="px-2 py-0.5 bg-neutral-700 border border-neutral-800 text-neutral-400 text-[10px] font-mono rounded whitespace-nowrap">{quantMatch}</span>
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hfHasSearched && !hfLoading && hfQuery && hfResults.length === 0 && (
|
||||
<p className="text-[12px] text-neutral-500 leading-relaxed">Nenhum resultado. Tente outra busca.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { CTX_PRESETS, MIN_CTX_SIZE } from "../../runtime/runtime-defaults";
|
||||
import type { RuntimeConfig, RuntimeGpuInfo } from "../../runtime/types";
|
||||
|
||||
interface Props {
|
||||
draftConfig: RuntimeConfig;
|
||||
updateDraft: (patch: Partial<RuntimeConfig>) => void;
|
||||
gpuInfo: RuntimeGpuInfo | null;
|
||||
busyAll: boolean;
|
||||
localError: string;
|
||||
}
|
||||
|
||||
export function RuntimeTab({
|
||||
draftConfig,
|
||||
updateDraft,
|
||||
gpuInfo,
|
||||
busyAll,
|
||||
localError,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<header className="mb-7 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[22px] font-semibold text-neutral-100 tracking-tight mb-1">Runtime</h2>
|
||||
<p className="text-[13px] text-neutral-400 leading-relaxed">
|
||||
GPU acceleration, context window, and startup policy.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{localError && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2 leading-relaxed">
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
{/* GPU acceleration */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">GPU acceleration</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">
|
||||
{gpuInfo ? `${gpuInfo.name} · ${gpuInfo.vram_mib} MiB VRAM` : "No GPU detected — CPU only"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
{gpuInfo && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase tracking-wider bg-green-500/10 text-green-500 border border-green-500/20 shrink-0">
|
||||
GPU detected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
|
||||
{/* GPU layers */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">GPU layers</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Layers to offload to the GPU</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-stretch">
|
||||
<div className="text-xs font-semibold text-neutral-300 font-mono">{draftConfig.gpu_layers}</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={gpuInfo ? Math.max(gpuInfo.recommended_layers, draftConfig.gpu_layers, 64) : 64}
|
||||
value={draftConfig.gpu_layers}
|
||||
onChange={(e) => updateDraft({ gpu_layers: Number(e.target.value) })}
|
||||
className="w-full h-1 bg-neutral-800 rounded-full appearance-none outline-none cursor-pointer mt-2 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-[0_0_5px_rgba(75,134,255,0.4)] hover:[&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(75,134,255,0.2)] transition-all"
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-neutral-500 bg-transparent border-none cursor-pointer px-1.5 py-0.5 rounded hover:text-blue-400 transition-colors"
|
||||
onClick={() => updateDraft({ gpu_layers: 0 })}
|
||||
>
|
||||
0 — CPU only
|
||||
</button>
|
||||
{gpuInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-neutral-500 bg-transparent border-none cursor-pointer px-1.5 py-0.5 rounded hover:text-blue-400 transition-colors"
|
||||
onClick={() => updateDraft({ gpu_layers: gpuInfo.recommended_layers })}
|
||||
>
|
||||
{gpuInfo.recommended_layers} — Recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
|
||||
{/* Context window */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Context window</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">Maximum tokens the model can process</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-stretch">
|
||||
<div className="text-xs font-semibold text-neutral-300 font-mono">{draftConfig.ctx_size.toLocaleString()} tokens</div>
|
||||
<div className="inline-flex bg-neutral-800 border border-neutral-700 rounded-md p-0.5 gap-0.5 w-fit">
|
||||
{CTX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => updateDraft({ ctx_size: preset })}
|
||||
className={`px-3.5 py-1.5 rounded text-xs font-medium cursor-pointer bg-transparent border-none transition-colors whitespace-nowrap ${
|
||||
draftConfig.ctx_size === preset
|
||||
? "bg-neutral-700 text-neutral-100 shadow-sm"
|
||||
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700/50"
|
||||
}`}
|
||||
disabled={busyAll}
|
||||
>
|
||||
{preset >= 1024 ? `${preset / 1024}K` : preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
className="w-[120px] px-2.5 py-1.5 text-xs bg-neutral-800 border border-neutral-700 rounded-md text-neutral-100 outline-none transition-all placeholder:text-neutral-500 hover:border-neutral-600 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15 disabled:opacity-45 disabled:cursor-not-allowed"
|
||||
value={draftConfig.ctx_size}
|
||||
min={MIN_CTX_SIZE}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= MIN_CTX_SIZE) updateDraft({ ctx_size: v });
|
||||
}}
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<span className="text-xs text-neutral-500 leading-relaxed">Min {MIN_CTX_SIZE.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-800 mx-5" />
|
||||
|
||||
{/* Startup policy */}
|
||||
<div className="flex items-start justify-between gap-6 p-4 px-5">
|
||||
<div className="min-w-0 shrink-0 w-[180px] pt-0.5">
|
||||
<div className="text-sm font-medium text-neutral-100 mb-0.5">Startup policy</div>
|
||||
<div className="text-xs text-neutral-400 leading-relaxed">When to start the local API</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-4">
|
||||
{(["manual", "on_app_launch"] as const).map((policy) => {
|
||||
const active = draftConfig.start_policy === policy;
|
||||
return (
|
||||
<label key={policy} className={`flex items-center gap-2 cursor-pointer text-[13px] transition-colors ${active ? "text-neutral-100" : "text-neutral-400"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="hidden"
|
||||
checked={active}
|
||||
onChange={() => updateDraft({ start_policy: policy })}
|
||||
disabled={busyAll}
|
||||
/>
|
||||
<span className={`relative w-9 h-5 rounded-full border transition-colors shrink-0 ${
|
||||
active ? "bg-blue-500 border-blue-500" : "bg-neutral-800 border-neutral-700"
|
||||
}`}>
|
||||
<span className={`absolute top-[2px] left-[2px] w-3.5 h-3.5 rounded-full transition-all ${
|
||||
active ? "bg-white translate-x-4" : "bg-neutral-500"
|
||||
}`} />
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{policy === "manual" ? "Manual" : "Auto-start"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 leading-relaxed text-right">
|
||||
{draftConfig.start_policy === "manual"
|
||||
? "Start the local API from the runtime panel when needed."
|
||||
: "Automatically start the local API when ForgeLM opens."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Box, Zap, ArrowLeftRight, Settings2 } from "lucide-react";
|
||||
import { getRuntimeStateLabel, getRuntimeStatusTone } from "../../runtime/runtime-utils";
|
||||
import type { RuntimeSnapshot } from "../../runtime/types";
|
||||
|
||||
export type Section = "model" | "runtime" | "connection" | "advanced";
|
||||
|
||||
const NAV: { id: Section; label: string; icon: typeof Box }[] = [
|
||||
{ id: "model", label: "Model", icon: Box },
|
||||
{ id: "runtime", label: "Runtime", icon: Zap },
|
||||
{ id: "connection", label: "Connection", icon: ArrowLeftRight },
|
||||
{ id: "advanced", label: "Advanced", icon: Settings2 },
|
||||
];
|
||||
|
||||
function statusColor(snapshot: RuntimeSnapshot): string {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
if (tone === "success") return "bg-green-500 shadow-[0_0_6px_rgba(48,209,88,0.55)]";
|
||||
if (tone === "warning") return "bg-yellow-500 animate-pulse";
|
||||
if (tone === "danger") return "bg-red-500";
|
||||
return "bg-neutral-500";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
section: Section;
|
||||
setSection: (section: Section) => void;
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ section, setSection, snapshot, loading, onClose }: Props) {
|
||||
const runtimeStatusLabel = loading ? "Loading..." : getRuntimeStateLabel(snapshot);
|
||||
|
||||
return (
|
||||
<nav className="w-[220px] min-w-[220px] bg-neutral-900 border-r border-neutral-800 flex flex-col h-screen overflow-hidden">
|
||||
<div className="p-6 pb-4 border-b border-neutral-800 shrink-0">
|
||||
<div className="text-[11px] font-semibold text-neutral-500 tracking-widest uppercase mb-1">ForgeLM</div>
|
||||
<h2 className="text-lg font-semibold text-neutral-100 tracking-tight">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = section === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSection(item.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium rounded-md w-full transition-colors text-left ${
|
||||
isActive
|
||||
? "bg-neutral-800 text-neutral-100 border-l-2 border-blue-500"
|
||||
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100 border-l-2 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-neutral-800 shrink-0">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${statusColor(snapshot)}`} />
|
||||
<span className="text-xs text-neutral-500">
|
||||
{runtimeStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 px-4 border-t border-neutral-800 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[13px] text-neutral-500 hover:text-neutral-100 transition-colors"
|
||||
>
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { searchHuggingFaceModels } from "../../runtime/api";
|
||||
import type { HuggingFaceModelResult } from "../../runtime/types";
|
||||
|
||||
export function useHuggingFaceSearch(token: string) {
|
||||
const [hfQuery, setHfQuery] = useState("");
|
||||
const [hfResults, setHfResults] = useState<HuggingFaceModelResult[]>([]);
|
||||
const [hfLoading, setHfLoading] = useState(false);
|
||||
const [hfHasSearched, setHfHasSearched] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleHfSearch = useCallback(async () => {
|
||||
if (!hfQuery.trim()) return;
|
||||
setHfLoading(true);
|
||||
setHfHasSearched(true);
|
||||
setError("");
|
||||
try {
|
||||
const results = await searchHuggingFaceModels(hfQuery, token);
|
||||
setHfResults(results);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setHfLoading(false);
|
||||
}
|
||||
}, [hfQuery, token]);
|
||||
|
||||
return {
|
||||
hfQuery,
|
||||
setHfQuery,
|
||||
hfResults,
|
||||
hfLoading,
|
||||
hfHasSearched,
|
||||
setHfHasSearched,
|
||||
error,
|
||||
handleHfSearch,
|
||||
};
|
||||
}
|
||||
@@ -22,22 +22,39 @@ const EXTRACTION_WINDOW = 6;
|
||||
// Minimum interval between extraction runs (ms) — avoids hammering on rapid sends
|
||||
const EXTRACTION_DEBOUNCE_MS = 4000;
|
||||
|
||||
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant. Your job is to extract factual, durable information about the user from a conversation fragment.
|
||||
const MIN_WORDS_FOR_EXTRACTION = 3;
|
||||
const MIN_CHARS_FOR_EXTRACTION = 15;
|
||||
|
||||
Rules:
|
||||
- Extract ONLY stable, reusable facts (name, occupation, location, preferences, projects, skills, goals, constraints)
|
||||
- Ignore ephemeral requests ("what time is it?") or single-use context
|
||||
- Output ONLY valid JSON — an array of objects with fields: key (snake_case, unique identifier), value (concise string), confidence (0.0–1.0)
|
||||
- If nothing worth remembering is found, output an empty array: []
|
||||
- Keep each value under 120 characters
|
||||
- Prefer updating an existing key over creating a near-duplicate
|
||||
const EXTRACTION_SYSTEM_PROMPT = `You are a strict JSON data extractor. Your ONLY job is to extract PERMANENT facts about the user from conversation.
|
||||
|
||||
Example output:
|
||||
[
|
||||
{"key": "user_name", "value": "Ana", "confidence": 0.95},
|
||||
{"key": "preferred_language", "value": "Python", "confidence": 0.9},
|
||||
{"key": "current_project", "value": "Building a local LLM chat app with Tauri", "confidence": 0.85}
|
||||
]`;
|
||||
STRICT RULES — FOLLOW EXACTLY:
|
||||
1. IGNORE greetings ("olá", "oi", "tudo bem", "hello", "hi")
|
||||
2. IGNORE short pleasantries ("ok", "obrigado", "valeu", "thanks")
|
||||
3. IGNORE questions about time, date, or weather
|
||||
4. Extract ONLY durable facts: name, occupation, location, preferences, projects, skills, goals
|
||||
5. Output MUST be valid JSON array — no markdown, no explanations
|
||||
6. Each object needs: key (snake_case), value (concise, under 120 chars), confidence (0.0-1.0)
|
||||
7. If NO durable facts found, output: []
|
||||
8. NEVER invent or guess — only extract what is explicitly stated
|
||||
|
||||
EXAMPLES:
|
||||
|
||||
User: "ola, tudo bem?"
|
||||
Output: []
|
||||
|
||||
User: "meu nome é Carlos e eu adoro cachorros"
|
||||
Output: [{"key": "user_name", "value": "Carlos", "confidence": 0.95}, {"key": "likes", "value": "cachorros", "confidence": 0.9}]
|
||||
|
||||
User: "Estou criando um app em Tauri"
|
||||
Output: [{"key": "current_project", "value": "app em Tauri", "confidence": 0.9}]
|
||||
|
||||
User: "qual a previsão do tempo?"
|
||||
Output: []
|
||||
|
||||
User: "me chame de Ana, trabalho com Python"
|
||||
Output: [{"key": "user_name", "value": "Ana", "confidence": 0.95}, {"key": "occupation", "value": "trabalha com Python", "confidence": 0.85}]
|
||||
|
||||
Now extract facts from the conversation below:`;
|
||||
|
||||
export interface MemoryExtractorOptions {
|
||||
db: DB;
|
||||
@@ -97,6 +114,22 @@ export function useMemoryExtractor({
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessages = recentMessages.filter((m) => m.role === "user");
|
||||
const recentUserContent = userMessages
|
||||
.slice(-3)
|
||||
.map((m) => m.content)
|
||||
.join(" ");
|
||||
const wordCount = recentUserContent.trim().split(/\s+/).length;
|
||||
const charCount = recentUserContent.length;
|
||||
|
||||
if (wordCount < MIN_WORDS_FOR_EXTRACTION || charCount < MIN_CHARS_FOR_EXTRACTION) {
|
||||
db.run(
|
||||
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
|
||||
[chatId],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationText = recentMessages
|
||||
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 400)}`)
|
||||
.join("\n\n");
|
||||
@@ -180,28 +213,27 @@ export function useMemoryExtractor({
|
||||
[db, server, apiKey, memoryModel, enabled],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reads all stored memories and formats them as a system prompt string.
|
||||
* Returns empty string if no memories exist.
|
||||
*/
|
||||
const buildMemorySystemPrompt = useCallback((): string => {
|
||||
const memories = db.query<UserMemory>(
|
||||
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 40",
|
||||
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 15",
|
||||
);
|
||||
if (memories.length === 0) return "";
|
||||
|
||||
const lines = memories
|
||||
const facts = memories
|
||||
.filter((m) => m.confidence >= 0.6)
|
||||
.map((m) => `- ${m.key.replace(/_/g, " ")}: ${m.value}`);
|
||||
.map((m) => {
|
||||
const label = m.key.replace(/_/g, " ");
|
||||
return `${label}: ${m.value}`;
|
||||
});
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
if (facts.length === 0) return "";
|
||||
|
||||
return [
|
||||
"You are a helpful assistant with memory of this user.",
|
||||
"Known facts about the user:",
|
||||
...lines,
|
||||
"You are a helpful assistant. Here is what you know about the user:",
|
||||
"",
|
||||
"Use this context naturally — don't repeat it verbatim, just let it inform your responses.",
|
||||
...facts,
|
||||
"",
|
||||
"Use this context naturally in your responses.",
|
||||
].join("\n");
|
||||
}, [db]);
|
||||
|
||||
|
||||
+16
-1
@@ -1,6 +1,18 @@
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
button, input, textarea, select {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Updated to Manus design tokens */
|
||||
--bg: #171717; /* Manus bg-base */
|
||||
@@ -51,12 +63,15 @@
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
/* Transparent root so the Tauri frameless window background shows through */
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* The real app bg lives on .app-layout, not html — keeps the content opaque */
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAssistantResponse } from "./chat";
|
||||
import { isLoopedOrCorruptedResponse, parseAssistantResponse, stripPromptEchoAndTranscriptLeakage } from "./chat";
|
||||
|
||||
describe("parseAssistantResponse", () => {
|
||||
it("removes leaked role transcript after the assistant answer", () => {
|
||||
@@ -20,4 +20,67 @@ describe("parseAssistantResponse", () => {
|
||||
expect(parsed.reasoning).toBe("análise interna");
|
||||
expect(parsed.content).toBe("Explicação útil.");
|
||||
});
|
||||
|
||||
it("removes markdown role transcript leakage", () => {
|
||||
const parsed = parseAssistantResponse(
|
||||
"Resposta objetiva.\n\n# System: You are a precise coding assistant running locally.",
|
||||
);
|
||||
|
||||
expect(parsed.content).toBe("Resposta objetiva.");
|
||||
expect(parsed.reasoning).toBe("");
|
||||
});
|
||||
|
||||
it("drops fenced prompt echo when the visible content starts with leaked transcript", () => {
|
||||
const parsed = parseAssistantResponse(
|
||||
"```markdown\n# System: You are a precise coding assistant running locally.\nPrioritize correct code.\n```",
|
||||
);
|
||||
|
||||
expect(parsed.content).toBe("");
|
||||
expect(parsed.reasoning).toBe("");
|
||||
});
|
||||
|
||||
it("drops dangling closing think tags before the visible answer", () => {
|
||||
const parsed = parseAssistantResponse(
|
||||
"</think>\n\nResposta final sem vazamento.",
|
||||
);
|
||||
|
||||
expect(parsed.content).toBe("Resposta final sem vazamento.");
|
||||
expect(parsed.reasoning).toBe("");
|
||||
});
|
||||
|
||||
it("removes a leaked transcript inside a leading code fence and keeps the real suffix", () => {
|
||||
const parsed = parseAssistantResponse(
|
||||
"```python\n# System: You are a precise coding assistant running locally.\nUser: teste\nAssistant: resposta vazada\n```\n\nA alternativa correta é a quarta opção.",
|
||||
);
|
||||
|
||||
expect(parsed.content).toBe("A alternativa correta é a quarta opção.");
|
||||
expect(parsed.reasoning).toBe("");
|
||||
});
|
||||
|
||||
it("drops leaked prompt echo after a think block when there is no useful final answer", () => {
|
||||
const parsed = parseAssistantResponse(
|
||||
"<think>análise longa</think>\n\n# System: You are a precise coding assistant running locally.\nPrioritize correct code.",
|
||||
);
|
||||
|
||||
expect(parsed.reasoning).toBe("análise longa");
|
||||
expect(parsed.content).toBe("");
|
||||
});
|
||||
|
||||
it("preserves normal markdown headings that are not transcript leakage", () => {
|
||||
const visible = stripPromptEchoAndTranscriptLeakage(
|
||||
"# Resposta\n\nAqui está a solução em tópicos.",
|
||||
);
|
||||
|
||||
expect(visible).toBe("# Resposta\n\nAqui está a solução em tópicos.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopedOrCorruptedResponse", () => {
|
||||
it("flags the local coding assistant prompt echo as corruption", () => {
|
||||
expect(
|
||||
isLoopedOrCorruptedResponse(
|
||||
"# System: You are a precise coding assistant running locally. Prioritize correct code.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -92,9 +92,25 @@ function stripLeadingFencedTranscriptLeakage(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip prompt echo / transcript leakage from the visible assistant content.
|
||||
*/
|
||||
const ROLE_TRANSCRIPT_PATTERN = /(?:^|\n)\s*(?:Assistant|User|System|Developer)\s*[:\-]\s*/i;
|
||||
const MARKDOWN_ROLE_PATTERN = /(?:^|\n)\s*#{0,6}\s*(?:System|Developer|Assistant|User)\s*:\s*/i;
|
||||
|
||||
function stripTrailingTranscriptLeakage(text: string): string {
|
||||
// Find the first occurrence of a role transcript pattern and cut everything after it
|
||||
const roleMatch = text.search(ROLE_TRANSCRIPT_PATTERN);
|
||||
if (roleMatch !== -1) {
|
||||
return text.slice(0, roleMatch).trimEnd();
|
||||
}
|
||||
|
||||
// Also check for markdown-style role headers
|
||||
const markdownMatch = text.search(MARKDOWN_ROLE_PATTERN);
|
||||
if (markdownMatch !== -1) {
|
||||
return text.slice(0, markdownMatch).trimEnd();
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function stripPromptEchoAndTranscriptLeakage(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
@@ -115,7 +131,10 @@ export function stripPromptEchoAndTranscriptLeakage(text: string): string {
|
||||
if (/^\s*(?:#{1,6}\s*)?(?:System|Developer)\s*:\s*you are a precise coding assistant running locally\.?/i.test(fenceStripped)) return "";
|
||||
if (/^\s*(?:#{1,6}\s*)?(?:System|Developer)\s*:\s*you are a helpful local ai assistant/i.test(trimmed)) return "";
|
||||
|
||||
return withoutLeadingLeakedFence;
|
||||
// Strip trailing transcript leakage (role labels that leaked after the actual response)
|
||||
const withoutTrailingLeakage = stripTrailingTranscriptLeakage(withoutLeadingLeakedFence);
|
||||
|
||||
return withoutTrailingLeakage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DB } from "./db";
|
||||
|
||||
interface FakeStatementRow {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear();
|
||||
}),
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: originalLocalStorage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createStatement(rows: FakeStatementRow[]) {
|
||||
let index = -1;
|
||||
|
||||
@@ -63,3 +94,39 @@ describe("DB.insert", () => {
|
||||
expect(prepare).toHaveBeenCalledWith("SELECT CAST(MAX(id) AS INTEGER) as id FROM chats");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DB persistence", () => {
|
||||
it("persists writes into the current localStorage key", () => {
|
||||
const db = new DB({
|
||||
run: vi.fn(),
|
||||
prepare: vi.fn(() => createStatement([])),
|
||||
export: vi.fn(() => new Uint8Array([70, 111, 114, 103, 101])),
|
||||
close: vi.fn(),
|
||||
});
|
||||
|
||||
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [1]);
|
||||
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("forgelm_db_v2", expect.any(String));
|
||||
});
|
||||
|
||||
it("persists inserts after reading the generated row id", () => {
|
||||
const prepare = vi.fn((sql: string) => {
|
||||
if (sql.includes("last_insert_rowid")) {
|
||||
return createStatement([{ id: 5 }]);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected query: ${sql}`);
|
||||
});
|
||||
|
||||
const db = new DB({
|
||||
run: vi.fn(),
|
||||
prepare,
|
||||
export: vi.fn(() => new Uint8Array([1, 2, 3])),
|
||||
close: vi.fn(),
|
||||
});
|
||||
|
||||
expect(db.insert("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [1, "oi"])).toBe(5);
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("forgelm_db_v2", expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* DevLogger — Development-only comprehensive interaction logger.
|
||||
*
|
||||
* Captures the FULL lifecycle of every chat interaction:
|
||||
* 1. User input + context assembly
|
||||
* 2. API request (headers, body, model, params)
|
||||
* 3. SSE stream chunks (raw + parsed)
|
||||
* 4. Response parsing (thinking extraction, transcript cleanup)
|
||||
* 5. DB writes (what was persisted)
|
||||
* 6. Error states + recovery actions
|
||||
*
|
||||
* All data is held in memory and can be exported as JSON or viewed
|
||||
* in the browser console via `window.__devLog`.
|
||||
*
|
||||
* Only active when import.meta.env.DEV is true.
|
||||
*/
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface InteractionLog {
|
||||
id: string;
|
||||
chatId: number | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
durationMs: number | null;
|
||||
status: "pending" | "streaming" | "complete" | "error" | "aborted";
|
||||
|
||||
// 1. User input
|
||||
userInput: {
|
||||
rawText: string;
|
||||
charCount: number;
|
||||
estimatedTokens: number;
|
||||
};
|
||||
|
||||
// 2. Context sent to API
|
||||
context: {
|
||||
messageCount: number;
|
||||
messages: Array<{ role: string; contentPreview: string; contentLength: number }>;
|
||||
totalContextChars: number;
|
||||
memoryEnabled: boolean;
|
||||
agenticMode: boolean;
|
||||
};
|
||||
|
||||
// 3. API request
|
||||
request: {
|
||||
url: string;
|
||||
method: string;
|
||||
model: string;
|
||||
stream: boolean;
|
||||
maxTokens: number;
|
||||
stopSequences: string[];
|
||||
headers: Record<string, string>;
|
||||
bodySize: number;
|
||||
fullBody: unknown;
|
||||
};
|
||||
|
||||
// 4. API response
|
||||
response: {
|
||||
httpStatus: number | null;
|
||||
httpStatusText: string | null;
|
||||
contentType: string | null;
|
||||
responseHeaders: Record<string, string>;
|
||||
};
|
||||
|
||||
// 5. Streaming data
|
||||
streaming: {
|
||||
chunksReceived: number;
|
||||
rawChunks: Array<{
|
||||
index: number;
|
||||
timestamp: string;
|
||||
raw: string;
|
||||
parsed: unknown | null;
|
||||
deltaContent: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
totalDeltaChars: number;
|
||||
tokenCount: number;
|
||||
firstTokenAt: string | null;
|
||||
lastTokenAt: string | null;
|
||||
timeToFirstTokenMs: number | null;
|
||||
};
|
||||
|
||||
// 6. Parsed result
|
||||
parsed: {
|
||||
rawContent: string;
|
||||
visibleContent: string;
|
||||
reasoningContent: string;
|
||||
thinkingOpen: boolean;
|
||||
isCorrupted: boolean;
|
||||
transcriptLeakageDetected: boolean;
|
||||
contentLength: number;
|
||||
};
|
||||
|
||||
// 7. Usage stats from API
|
||||
usage: {
|
||||
promptTokens: number | null;
|
||||
completionTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
tokensPerSecond: number | null;
|
||||
};
|
||||
|
||||
// 8. DB persistence
|
||||
dbWrites: Array<{
|
||||
timestamp: string;
|
||||
operation: string;
|
||||
table: string;
|
||||
params: unknown[];
|
||||
resultId: number | null;
|
||||
}>;
|
||||
|
||||
// 9. Errors
|
||||
errors: Array<{
|
||||
timestamp: string;
|
||||
phase: string;
|
||||
message: string;
|
||||
stack: string | null;
|
||||
raw: unknown;
|
||||
}>;
|
||||
|
||||
// 10. Sanitization / encoding diagnostics
|
||||
sanitization: Array<{
|
||||
timestamp: string;
|
||||
stage: string;
|
||||
changed: boolean;
|
||||
repairedEncoding: boolean;
|
||||
beforePreview: string;
|
||||
afterPreview: string;
|
||||
}>;
|
||||
|
||||
// 11. Timeline of phases
|
||||
timeline: Array<{
|
||||
timestamp: string;
|
||||
phase: string;
|
||||
detail: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Logger Class ────────────────────────────────────────────────
|
||||
|
||||
const IS_DEV = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
||||
const MAX_LOGS = 50;
|
||||
|
||||
class DevLogger {
|
||||
private interactions: InteractionLog[] = [];
|
||||
private current: InteractionLog | null = null;
|
||||
private enabled: boolean;
|
||||
private startTime: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.enabled = IS_DEV;
|
||||
if (this.enabled && typeof window !== "undefined") {
|
||||
(window as unknown as Record<string, unknown>).__devLog = this;
|
||||
console.log(
|
||||
"%c[DevLogger] 🔍 Development logging ACTIVE — access via window.__devLog",
|
||||
"color: #22c55e; font-weight: bold; font-size: 13px",
|
||||
);
|
||||
console.log(
|
||||
"%c .current → current interaction\n .interactions → all logged interactions\n .export() → JSON export\n .dump() → console table\n .dumpLast() → last interaction detail\n .clear() → reset",
|
||||
"color: #999; font-size: 11px",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interaction Lifecycle ──
|
||||
|
||||
startInteraction(chatId: number | null, userText: string, opts: {
|
||||
memoryEnabled: boolean;
|
||||
agenticMode: boolean;
|
||||
}): string {
|
||||
if (!this.enabled) return "";
|
||||
|
||||
const id = `int_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
this.startTime = performance.now();
|
||||
|
||||
this.current = {
|
||||
id,
|
||||
chatId,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
durationMs: null,
|
||||
status: "pending",
|
||||
userInput: {
|
||||
rawText: userText,
|
||||
charCount: userText.length,
|
||||
estimatedTokens: Math.ceil(userText.length / 4),
|
||||
},
|
||||
context: {
|
||||
messageCount: 0,
|
||||
messages: [],
|
||||
totalContextChars: 0,
|
||||
memoryEnabled: opts.memoryEnabled,
|
||||
agenticMode: opts.agenticMode,
|
||||
},
|
||||
request: {
|
||||
url: "",
|
||||
method: "POST",
|
||||
model: "",
|
||||
stream: true,
|
||||
maxTokens: 0,
|
||||
stopSequences: [],
|
||||
headers: {},
|
||||
bodySize: 0,
|
||||
fullBody: null,
|
||||
},
|
||||
response: {
|
||||
httpStatus: null,
|
||||
httpStatusText: null,
|
||||
contentType: null,
|
||||
responseHeaders: {},
|
||||
},
|
||||
streaming: {
|
||||
chunksReceived: 0,
|
||||
rawChunks: [],
|
||||
totalDeltaChars: 0,
|
||||
tokenCount: 0,
|
||||
firstTokenAt: null,
|
||||
lastTokenAt: null,
|
||||
timeToFirstTokenMs: null,
|
||||
},
|
||||
parsed: {
|
||||
rawContent: "",
|
||||
visibleContent: "",
|
||||
reasoningContent: "",
|
||||
thinkingOpen: false,
|
||||
isCorrupted: false,
|
||||
transcriptLeakageDetected: false,
|
||||
contentLength: 0,
|
||||
},
|
||||
usage: {
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
totalTokens: null,
|
||||
tokensPerSecond: null,
|
||||
},
|
||||
dbWrites: [],
|
||||
errors: [],
|
||||
sanitization: [],
|
||||
timeline: [],
|
||||
};
|
||||
|
||||
this.addTimeline("init", `Interaction started — chatId=${chatId}, text="${userText.slice(0, 60)}…"`);
|
||||
this.logPhase("📝 User Input", { text: userText.slice(0, 200), chars: userText.length });
|
||||
return id;
|
||||
}
|
||||
|
||||
logContext(messages: Array<{ role: string; content: string }>) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const ctx = this.current.context;
|
||||
ctx.messageCount = messages.length;
|
||||
ctx.messages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
contentPreview: m.content.slice(0, 100),
|
||||
contentLength: m.content.length,
|
||||
}));
|
||||
ctx.totalContextChars = messages.reduce((sum, m) => sum + m.content.length, 0);
|
||||
|
||||
this.addTimeline("context", `${messages.length} messages, ${ctx.totalContextChars} chars total`);
|
||||
this.logPhase("📦 Context Assembly", {
|
||||
messageCount: messages.length,
|
||||
totalChars: ctx.totalContextChars,
|
||||
roles: messages.map((m) => m.role).join(", "),
|
||||
messages: ctx.messages,
|
||||
});
|
||||
}
|
||||
|
||||
logRequest(url: string, body: unknown, headers: Record<string, string>) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const req = this.current.request;
|
||||
req.url = url;
|
||||
req.fullBody = body;
|
||||
req.bodySize = bodyStr.length;
|
||||
req.headers = sanitizeHeaders(headers);
|
||||
|
||||
if (typeof body === "object" && body !== null) {
|
||||
const b = body as Record<string, unknown>;
|
||||
req.model = String(b.model ?? "");
|
||||
req.stream = Boolean(b.stream);
|
||||
req.maxTokens = Number(b.max_tokens ?? 0);
|
||||
req.stopSequences = Array.isArray(b.stop) ? (b.stop as string[]) : [];
|
||||
}
|
||||
|
||||
this.addTimeline("request", `POST ${url} — model=${req.model}, stream=${req.stream}, body=${bodyStr.length}B`);
|
||||
this.logPhase("🚀 API Request", {
|
||||
url,
|
||||
model: req.model,
|
||||
stream: req.stream,
|
||||
maxTokens: req.maxTokens,
|
||||
bodySize: bodyStr.length,
|
||||
stopSequences: req.stopSequences,
|
||||
fullBody: body,
|
||||
});
|
||||
}
|
||||
|
||||
logResponse(res: Response) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const resp = this.current.response;
|
||||
resp.httpStatus = res.status;
|
||||
resp.httpStatusText = res.statusText;
|
||||
resp.contentType = res.headers.get("content-type");
|
||||
res.headers.forEach((value, key) => {
|
||||
resp.responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
this.addTimeline("response", `HTTP ${res.status} ${res.statusText} — ${resp.contentType}`);
|
||||
this.logPhase("📡 API Response", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
contentType: resp.contentType,
|
||||
headers: resp.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
logStreamChunk(raw: string, parsed: unknown | null, deltaContent: string | null, error: string | null = null) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const s = this.current.streaming;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
s.chunksReceived++;
|
||||
|
||||
// Store first 100 chunks in full + every 50th after that for inspection
|
||||
if (s.chunksReceived <= 100 || s.chunksReceived % 50 === 0) {
|
||||
s.rawChunks.push({
|
||||
index: s.chunksReceived,
|
||||
timestamp: now,
|
||||
raw: raw.slice(0, 500),
|
||||
parsed,
|
||||
deltaContent,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
if (deltaContent) {
|
||||
s.totalDeltaChars += deltaContent.length;
|
||||
s.tokenCount++;
|
||||
s.lastTokenAt = now;
|
||||
|
||||
if (!s.firstTokenAt) {
|
||||
s.firstTokenAt = now;
|
||||
s.timeToFirstTokenMs = performance.now() - this.startTime;
|
||||
this.addTimeline("first_token", `TTFT=${s.timeToFirstTokenMs.toFixed(0)}ms, delta="${deltaContent}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.current.errors.push({ timestamp: now, phase: "stream_chunk", message: error, stack: null, raw });
|
||||
}
|
||||
|
||||
// Log first 3, then every 50th chunk to console
|
||||
if (s.chunksReceived <= 3 || s.chunksReceived % 50 === 0) {
|
||||
console.debug(
|
||||
`%c[DevLogger] 🔄 Chunk #${s.chunksReceived}`,
|
||||
"color: #60a5fa",
|
||||
{ delta: deltaContent, totalChars: s.totalDeltaChars, raw: raw.slice(0, 120) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logNonStreamResponse(data: unknown) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
this.addTimeline("non_stream_response", "Full response received (non-streaming)");
|
||||
this.logPhase("📩 Non-Stream Response", data);
|
||||
}
|
||||
|
||||
logParsedResult(result: {
|
||||
rawContent: string;
|
||||
visibleContent: string;
|
||||
reasoningContent: string;
|
||||
thinkingOpen: boolean;
|
||||
isCorrupted: boolean;
|
||||
transcriptLeakageDetected: boolean;
|
||||
}) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const p = this.current.parsed;
|
||||
Object.assign(p, result);
|
||||
p.contentLength = result.visibleContent.length;
|
||||
|
||||
this.addTimeline("parsed", `visible=${result.visibleContent.length}ch, reasoning=${result.reasoningContent.length}ch, corrupted=${result.isCorrupted}`);
|
||||
|
||||
const logStyle = result.isCorrupted
|
||||
? "%c[DevLogger] ⚠️ PARSED RESULT (CORRUPTED)"
|
||||
: "%c[DevLogger] ✅ Parsed Result";
|
||||
const logColor = result.isCorrupted ? "color: #ef4444; font-weight: bold" : "color: #22c55e";
|
||||
|
||||
console.groupCollapsed(logStyle, logColor);
|
||||
console.log("Raw content:", result.rawContent);
|
||||
console.log("Visible content:", result.visibleContent);
|
||||
console.log("Reasoning:", result.reasoningContent || "(none)");
|
||||
console.log("Thinking still open:", result.thinkingOpen);
|
||||
console.log("Corrupted:", result.isCorrupted);
|
||||
console.log("Transcript leak:", result.transcriptLeakageDetected);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
logUsage(usage: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
tokens_per_second?: number;
|
||||
} | null) {
|
||||
if (!this.enabled || !this.current || !usage) return;
|
||||
|
||||
this.current.usage = {
|
||||
promptTokens: usage.prompt_tokens ?? null,
|
||||
completionTokens: usage.completion_tokens ?? null,
|
||||
totalTokens: usage.total_tokens ?? null,
|
||||
tokensPerSecond: usage.tokens_per_second ?? null,
|
||||
};
|
||||
|
||||
this.addTimeline("usage", `prompt=${usage.prompt_tokens}, completion=${usage.completion_tokens}, tps=${usage.tokens_per_second?.toFixed(1)}`);
|
||||
this.logPhase("📊 Usage Stats", usage);
|
||||
}
|
||||
|
||||
logDBWrite(operation: string, table: string, params: unknown[], resultId: number | null = null) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
this.current.dbWrites.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
operation,
|
||||
table,
|
||||
params: params.map((p) => typeof p === "string" && p.length > 200 ? p.slice(0, 200) + "…" : p),
|
||||
resultId,
|
||||
});
|
||||
|
||||
this.addTimeline("db_write", `${operation} → ${table}${resultId ? ` (id=${resultId})` : ""}`);
|
||||
console.debug(`%c[DevLogger] 💾 DB ${operation}`, "color: #a855f7", { table, params: params.map((p) => typeof p === "string" && p.length > 100 ? p.slice(0, 100) + "…" : p), resultId });
|
||||
}
|
||||
|
||||
logSanitization(stage: string, payload: {
|
||||
before: string;
|
||||
after: string;
|
||||
changed: boolean;
|
||||
repairedEncoding?: boolean;
|
||||
}) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
this.current.sanitization.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
stage,
|
||||
changed: payload.changed,
|
||||
repairedEncoding: Boolean(payload.repairedEncoding),
|
||||
beforePreview: payload.before.slice(0, 240),
|
||||
afterPreview: payload.after.slice(0, 240),
|
||||
});
|
||||
|
||||
if (payload.changed || payload.repairedEncoding) {
|
||||
this.addTimeline(
|
||||
"sanitize",
|
||||
`${stage}: changed=${payload.changed} repairedEncoding=${Boolean(payload.repairedEncoding)}`,
|
||||
);
|
||||
console.warn(`%c[DevLogger] 🧼 Sanitization @ ${stage}`, "color: #f59e0b; font-weight: bold", {
|
||||
repairedEncoding: Boolean(payload.repairedEncoding),
|
||||
before: payload.before,
|
||||
after: payload.after,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logError(phase: string, error: unknown) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.current.errors.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
phase,
|
||||
message: err.message,
|
||||
stack: err.stack ?? null,
|
||||
raw: error,
|
||||
});
|
||||
this.current.status = "error";
|
||||
|
||||
this.addTimeline("error", `[${phase}] ${err.message}`);
|
||||
console.error(`%c[DevLogger] ❌ Error in ${phase}`, "color: #ef4444; font-weight: bold", err);
|
||||
}
|
||||
|
||||
logAbort(partialContent: string) {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
this.current.status = "aborted";
|
||||
this.addTimeline("abort", `User aborted — partial content: ${partialContent.length} chars`);
|
||||
this.logPhase("🛑 Aborted", { partialContentLength: partialContent.length, preview: partialContent.slice(0, 200) });
|
||||
}
|
||||
|
||||
finishInteraction() {
|
||||
if (!this.enabled || !this.current) return;
|
||||
|
||||
const elapsed = performance.now() - this.startTime;
|
||||
this.current.durationMs = Math.round(elapsed);
|
||||
this.current.finishedAt = new Date().toISOString();
|
||||
if (this.current.status === "pending" || this.current.status === "streaming") {
|
||||
this.current.status = "complete";
|
||||
}
|
||||
|
||||
this.addTimeline("finish", `Duration=${elapsed.toFixed(0)}ms, status=${this.current.status}`);
|
||||
|
||||
// Summary console output
|
||||
const c = this.current;
|
||||
console.groupCollapsed(
|
||||
`%c[DevLogger] 🏁 Interaction ${c.id} — ${c.status} in ${elapsed.toFixed(0)}ms`,
|
||||
c.status === "error" ? "color: #ef4444; font-weight: bold" : "color: #22c55e; font-weight: bold",
|
||||
);
|
||||
console.table({
|
||||
"User Input": `${c.userInput.charCount} chars`,
|
||||
"Context Messages": c.context.messageCount,
|
||||
"Context Chars": c.context.totalContextChars,
|
||||
"Model": c.request.model,
|
||||
"Stream": c.request.stream,
|
||||
"HTTP Status": c.response.httpStatus,
|
||||
"Chunks Received": c.streaming.chunksReceived,
|
||||
"TTFT (ms)": c.streaming.timeToFirstTokenMs?.toFixed(0) ?? "n/a",
|
||||
"Stream Tokens": c.streaming.tokenCount,
|
||||
"Stream Chars": c.streaming.totalDeltaChars,
|
||||
"Final Content": `${c.parsed.contentLength} chars`,
|
||||
"Reasoning": c.parsed.reasoningContent ? `${c.parsed.reasoningContent.length} chars` : "none",
|
||||
"Corrupted": c.parsed.isCorrupted,
|
||||
"Prompt Tokens": c.usage.promptTokens,
|
||||
"Completion Tokens": c.usage.completionTokens,
|
||||
"TPS": c.usage.tokensPerSecond?.toFixed(1) ?? "n/a",
|
||||
"DB Writes": c.dbWrites.length,
|
||||
"Sanitizations": c.sanitization.length,
|
||||
"Errors": c.errors.length,
|
||||
"Duration (ms)": elapsed.toFixed(0),
|
||||
});
|
||||
if (c.errors.length > 0) {
|
||||
console.warn("Errors:", c.errors);
|
||||
}
|
||||
if (c.parsed.isCorrupted) {
|
||||
console.warn("⚠️ CORRUPTED RESPONSE — raw content:", c.parsed.rawContent);
|
||||
}
|
||||
// Log response content comparison for debugging "response 1" issue
|
||||
if (c.parsed.visibleContent.trim().length <= 5) {
|
||||
console.warn(
|
||||
"⚠️ SUSPICIOUSLY SHORT RESPONSE:",
|
||||
{
|
||||
rawFromStream: c.streaming.totalDeltaChars,
|
||||
rawContent: c.parsed.rawContent,
|
||||
visibleContent: c.parsed.visibleContent,
|
||||
allChunks: c.streaming.rawChunks,
|
||||
},
|
||||
);
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
// Archive
|
||||
this.interactions.push(this.current);
|
||||
if (this.interactions.length > MAX_LOGS) {
|
||||
this.interactions = this.interactions.slice(-MAX_LOGS);
|
||||
}
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
// ── Public Inspection API ──
|
||||
|
||||
/** Export all logs as JSON (for file download / clipboard). */
|
||||
export(): string {
|
||||
return JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
interactionCount: this.interactions.length,
|
||||
interactions: this.interactions,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/** Console table of all interactions. */
|
||||
dump() {
|
||||
if (this.interactions.length === 0) {
|
||||
console.log("[DevLogger] No interactions recorded yet.");
|
||||
return;
|
||||
}
|
||||
console.table(
|
||||
this.interactions.map((i) => ({
|
||||
id: i.id,
|
||||
chatId: i.chatId,
|
||||
status: i.status,
|
||||
model: i.request.model,
|
||||
userInput: i.userInput.rawText.slice(0, 40),
|
||||
responseLen: i.parsed.contentLength,
|
||||
corrupted: i.parsed.isCorrupted,
|
||||
chunks: i.streaming.chunksReceived,
|
||||
ttft: i.streaming.timeToFirstTokenMs?.toFixed(0),
|
||||
tps: i.usage.tokensPerSecond?.toFixed(1),
|
||||
durationMs: i.durationMs,
|
||||
errors: i.errors.length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Detailed dump of the last interaction. */
|
||||
dumpLast() {
|
||||
const last = this.interactions[this.interactions.length - 1];
|
||||
if (!last) {
|
||||
console.log("[DevLogger] No interactions recorded yet.");
|
||||
return;
|
||||
}
|
||||
console.log(`[DevLogger] Last interaction: ${last.id}`);
|
||||
console.log("Timeline:");
|
||||
last.timeline.forEach((t) => console.log(` ${t.timestamp} [${t.phase}] ${t.detail}`));
|
||||
console.log("\nFull data:", last);
|
||||
}
|
||||
|
||||
/** Return the latest interaction, optionally filtered by chatId. */
|
||||
getLatestInteraction(chatId?: number | null): InteractionLog | null {
|
||||
if (this.interactions.length === 0) return null;
|
||||
if (chatId === undefined) return this.interactions[this.interactions.length - 1] ?? null;
|
||||
|
||||
for (let index = this.interactions.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = this.interactions[index];
|
||||
if (candidate.chatId === chatId) return candidate;
|
||||
}
|
||||
|
||||
return this.interactions[this.interactions.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Clear all logs. */
|
||||
clear() {
|
||||
this.interactions = [];
|
||||
this.current = null;
|
||||
console.log("[DevLogger] Cleared all logs.");
|
||||
}
|
||||
|
||||
// ── Internal helpers ──
|
||||
|
||||
private addTimeline(phase: string, detail: string) {
|
||||
this.current?.timeline.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
phase,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
private logPhase(label: string, data: unknown) {
|
||||
console.groupCollapsed(`%c[DevLogger] ${label}`, "color: #60a5fa; font-weight: bold");
|
||||
console.log(data);
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const safe = { ...headers };
|
||||
// Mask authorization tokens
|
||||
for (const key of Object.keys(safe)) {
|
||||
if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key")) {
|
||||
safe[key] = safe[key].slice(0, 10) + "…[MASKED]";
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
// ── Singleton Export ──
|
||||
|
||||
export const devLog = new DevLogger();
|
||||
@@ -8,13 +8,11 @@ describe("markdown rendering", () => {
|
||||
await ensureHljs();
|
||||
});
|
||||
|
||||
it("renders code blocks with bundled syntax highlighting and copy controls", () => {
|
||||
const html = renderMarkdown("```typescript\nconst value = 1;\n```");
|
||||
it("wraps markdown tables for horizontal overflow", () => {
|
||||
const html = renderMarkdown("| A | B |\n| - | - |\n| 1 | 2 |\n");
|
||||
|
||||
expect(html).toContain("code-block");
|
||||
expect(html).toContain("code-header");
|
||||
expect(html).toContain("copy-code-btn");
|
||||
expect(html).toContain("language-typescript");
|
||||
expect(html).toContain("hljs");
|
||||
expect(html).toContain("table-wrap");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<tbody>");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import rust from "highlight.js/lib/languages/rust";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import { sanitizeMessageText } from "./messageSanitizer";
|
||||
|
||||
let hlReady = false;
|
||||
|
||||
@@ -58,11 +59,16 @@ renderer.code = function (code: string, lang?: string) {
|
||||
</div>`;
|
||||
};
|
||||
|
||||
renderer.table = function (header: string, body: string) {
|
||||
return `<div class="table-wrap"><table>${header}<tbody>${body}</tbody></table></div>`;
|
||||
};
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
marked.use({ renderer });
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
const html = marked.parse(text) as string;
|
||||
const sanitizedInput = sanitizeMessageText(text).text;
|
||||
const html = marked.parse(sanitizedInput) as string;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ["div", "span", "button", "pre", "code"],
|
||||
ADD_ATTR: ["class", "data-code"],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeMessageText } from "./messageSanitizer";
|
||||
|
||||
describe("sanitizeMessageText", () => {
|
||||
it("removes orphan </think> and internal tokens", () => {
|
||||
const result = sanitizeMessageText("Olá</think> <|im_start|>mundo");
|
||||
expect(result.text).toBe("Olá mundo");
|
||||
expect(result.changed).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs common utf-8 mojibake", () => {
|
||||
const result = sanitizeMessageText("Opções e Veredito Final");
|
||||
expect(result.text).toContain("Opções");
|
||||
expect(result.repairedEncoding).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const ORPHAN_THINK_CLOSER = /<\/(?:think|thinking)>/gi;
|
||||
const INTERNAL_TOKEN_PATTERN = /<\|(?:im_start|im_end|start_of_answered_content|end_of_answered_content|[a-z_]+)\|>/gi;
|
||||
const REPLACEMENT_CHAR = /�/;
|
||||
const MOJIBAKE_HINT = /(Ã.|â.|ð.|œ|ž|¢|£|¤|¥|¦|§|¨|©|ª|«|¬|®|¯)/;
|
||||
|
||||
function shouldAttemptUtf8Repair(input: string): boolean {
|
||||
return REPLACEMENT_CHAR.test(input) || MOJIBAKE_HINT.test(input);
|
||||
}
|
||||
|
||||
function repairUtf8Mojibake(input: string): string {
|
||||
if (!shouldAttemptUtf8Repair(input)) return input;
|
||||
|
||||
try {
|
||||
const bytes = Uint8Array.from(Array.from(input, (char) => char.charCodeAt(0) & 0xff));
|
||||
const repaired = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
|
||||
const repairedScore = (repaired.match(REPLACEMENT_CHAR)?.length ?? 0) + (repaired.match(MOJIBAKE_HINT)?.length ?? 0);
|
||||
const originalScore = (input.match(REPLACEMENT_CHAR)?.length ?? 0) + (input.match(MOJIBAKE_HINT)?.length ?? 0);
|
||||
|
||||
return repairedScore < originalScore ? repaired : input;
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SanitizedMessageResult {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
repairedEncoding: boolean;
|
||||
}
|
||||
|
||||
export function sanitizeMessageText(input: string): SanitizedMessageResult {
|
||||
const original = input ?? "";
|
||||
let text = original;
|
||||
|
||||
text = text.replace(ORPHAN_THINK_CLOSER, "");
|
||||
text = text.replace(INTERNAL_TOKEN_PATTERN, "");
|
||||
|
||||
const repaired = repairUtf8Mojibake(text);
|
||||
const repairedEncoding = repaired !== text;
|
||||
text = repaired;
|
||||
|
||||
return {
|
||||
text,
|
||||
changed: text !== original,
|
||||
repairedEncoding,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { SYSTEM_PROMPT } from "../prompts/system";
|
||||
|
||||
describe("System Prompt Quality", () => {
|
||||
test("should be concise (under 100 tokens)", () => {
|
||||
const estimatedTokens = SYSTEM_PROMPT.length / 4;
|
||||
expect(estimatedTokens).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test("should not contain negative restrictions (don't, cannot, never)", () => {
|
||||
const lower = SYSTEM_PROMPT.toLowerCase();
|
||||
expect(lower).not.toContain("don't");
|
||||
expect(lower).not.toContain("cannot");
|
||||
expect(lower).not.toContain("never");
|
||||
expect(lower).not.toContain("must not");
|
||||
});
|
||||
|
||||
test("should mention language detection", () => {
|
||||
const lower = SYSTEM_PROMPT.toLowerCase();
|
||||
expect(lower).toMatch(/language|idioma/);
|
||||
});
|
||||
|
||||
test("should identify as helpful assistant", () => {
|
||||
expect(SYSTEM_PROMPT.toLowerCase()).toContain("helpful");
|
||||
expect(SYSTEM_PROMPT.toLowerCase()).toContain("assistant");
|
||||
});
|
||||
|
||||
test("should mention ForgeLM for branding", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("ForgeLM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* System Prompt
|
||||
*
|
||||
* A minimal context message injected as the first message of every request.
|
||||
*
|
||||
* Design rules:
|
||||
* - Keep it SHORT — every token here is a token NOT available for context history.
|
||||
* - NEVER restrict capabilities. Restrictions cause refusal loops on small models.
|
||||
* - NEVER mention what the model "cannot" do. Negations confuse small models.
|
||||
* - Positive framing only: tell the model what it IS, not what it cannot do.
|
||||
* - No JSON format requirements, no stop sequences, no role-play rules here.
|
||||
*
|
||||
* This prompt is intentionally generic so it works equally well for
|
||||
* Qwen3 0.8B, 2B, 4B and any other GGUF model loaded locally.
|
||||
*/
|
||||
export const SYSTEM_PROMPT = `You are a helpful local AI assistant running on the user's machine via ForgeLM Studio. Answer questions clearly and directly. Respond in the same language as the user.`;
|
||||
@@ -106,11 +106,6 @@ export interface StepItemProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export interface ArtifactViewerProps {
|
||||
artifact: Artifact;
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri event payloads
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user