[autoresearch] baseline: system prompt quality tests (5/5 passing)

This commit is contained in:
evandrodevbr committed 2026-04-11 05:27:36 -03:00
1 parent 05cc036f6d
commit 60b0101717
142 files changed
+18589 -9947

No files matched your search

+650
View File
@@ -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"
]
-4676
View File
File diff suppressed because it is too large. Load diff
-1862
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -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"]}}
+3 -2
View File
@@ -18,8 +18,9 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
"decorations": false,
"transparent": true,
"shadow": true
}
],
"security": {
+9
View File
@@ -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 {
+74 -3
View File
@@ -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);
});
});
+260
View File
@@ -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; }
}
+208
View File
@@ -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>
);
}
+176 -210
View File
@@ -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;
+25 -74
View File
@@ -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">
+129 -2
View File
@@ -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);
+16 -43
View File
@@ -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>");
});
});
+38 -7
View File
@@ -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">
+28 -4
View File
@@ -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>
+51 -94
View File
@@ -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.");
});
});
+208 -115
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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>
);
}
-198
View File
@@ -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>
);
}
-4
View File
@@ -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>
);
}
+22
View File
@@ -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,
};
}
+58 -26
View File
@@ -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
View File
@@ -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; }
+64 -1
View File
@@ -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);
});
});
+23 -4
View File
@@ -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;
}
/**
+68 -1
View File
@@ -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));
});
});
+658
View File
@@ -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();
+5 -7
View File
@@ -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>");
});
});
+7 -1
View File
@@ -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);
});
});
+48
View File
@@ -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");
});
});
+16
View File
@@ -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.`;
-5
View File
@@ -106,11 +106,6 @@ export interface StepItemProps {
isLast: boolean;
}
export interface ArtifactViewerProps {
artifact: Artifact;
onDownload?: () => void;
}
/**
* Tauri event payloads
*/