Add standalone /chat page with full-page conversation UI
Extract shared rendering (chat-render.ts) and streaming hook (use-chat-stream.ts) from ChatWidget so both the floating panel and the new page share identical markdown/KaTeX/SSE logic. New page features: - Responsive sidebar (inline desktop, Sheet drawer mobile) - Conversation search/filter - Notebook picker via cmdk command palette - Auto-growing multi-line textarea input - Pop-out button on widget header to open /chat ChatLayout.astro omits the floating widget to avoid duplicate UI. Chat store gains selectedNotebookId for page-level notebook context. shadcn-ui primitives (ScrollArea, Sheet, Command, Popover, etc.) wired to existing SpiceBook dark theme tokens.
This commit is contained in:
parent
70efde8aa6
commit
c0639c775c
18
frontend/components.json
Normal file
18
frontend/components.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/cn",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
908
frontend/package-lock.json
generated
908
frontend/package-lock.json
generated
@ -22,11 +22,19 @@
|
||||
"@iconify-json/lucide": "^1.2.90",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"astro": "^5.0.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-seo-meta": "^5.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.33",
|
||||
"lucide-react": "^0.468.0",
|
||||
@ -1710,6 +1718,44 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
|
||||
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
@ -2433,6 +2479,698 @@
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
|
||||
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
@ -3784,6 +4522,18 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@ -4739,6 +5489,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-boxes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||
@ -4847,6 +5609,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -5139,6 +5917,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deterministic-object-hash": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz",
|
||||
@ -5637,6 +6421,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
@ -7878,6 +8671,75 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
@ -8619,8 +9481,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
@ -9012,6 +9873,49 @@
|
||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@ -23,11 +23,19 @@
|
||||
"@iconify-json/lucide": "^1.2.90",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"astro": "^5.0.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-seo-meta": "^5.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.33",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
91
frontend/src/components/chat/ChatInput.tsx
Normal file
91
frontend/src/components/chat/ChatInput.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Send, Square, BookOpen } from 'lucide-react';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
streaming: boolean;
|
||||
notebookTitle?: string | null;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onAbort,
|
||||
streaming,
|
||||
notebookTitle,
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
}, [value]);
|
||||
|
||||
// Focus on mount
|
||||
useEffect(() => {
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!streaming && value.trim()) {
|
||||
onSend();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSend, streaming, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-page-input-container">
|
||||
{notebookTitle && (
|
||||
<div className="chat-page-input-context">
|
||||
<BookOpen size={12} />
|
||||
<span>{notebookTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-page-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-page-textarea"
|
||||
placeholder="Ask about circuits, netlists, or simulation…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={streaming}
|
||||
rows={1}
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
className="chat-page-send-btn abort"
|
||||
onClick={onAbort}
|
||||
title="Stop generating"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="chat-page-send-btn"
|
||||
onClick={onSend}
|
||||
disabled={!value.trim()}
|
||||
title="Send (Enter)"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-page-input-hint">
|
||||
<span>Enter to send, Shift+Enter for newline</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/chat/ChatMessages.tsx
Normal file
84
frontend/src/components/chat/ChatMessages.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ChatMessage } from '../../lib/chat-store';
|
||||
import { renderMarkdown } from '../../lib/chat-render';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
|
||||
return (
|
||||
<div className={`chat-page-bubble ${msg.role}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<>
|
||||
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
|
||||
{isStreaming && <span className="chat-page-cursor" />}
|
||||
</>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
statusText: string;
|
||||
reasoningText: string;
|
||||
reasoningTime: number;
|
||||
}
|
||||
|
||||
export default function ChatMessages({
|
||||
messages,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
}: ChatMessagesProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = bottomRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [messages.length, streaming]);
|
||||
|
||||
return (
|
||||
<div className="chat-page-messages">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="chat-page-empty">
|
||||
<div className="chat-page-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[var(--color-sb-text-bright)]">Circuit Assistant</h2>
|
||||
<p className="text-sm text-[var(--color-sb-muted)] max-w-md text-center">
|
||||
Ask about circuits, netlists, SPICE simulation, or component behavior.
|
||||
Select a notebook for context-aware answers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
msg={msg}
|
||||
isStreaming={streaming && i === messages.length - 1 && msg.role === 'assistant'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{reasoningText && streaming && (
|
||||
<details className="chat-page-reasoning">
|
||||
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
|
||||
<div className="chat-page-reasoning-body">{reasoningText}</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{statusText && (
|
||||
<div className="chat-page-status">{statusText}</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/chat/ChatPage.tsx
Normal file
129
frontend/src/components/chat/ChatPage.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Zap, PanelLeftClose, PanelLeft } from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import { useChatStream } from '../../lib/use-chat-stream';
|
||||
import { Sheet, SheetContent, SheetTitle } from '../ui/sheet';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import ChatSidebar from './ChatSidebar';
|
||||
import ChatMessages from './ChatMessages';
|
||||
import ChatInput from './ChatInput';
|
||||
import '../../styles/chat-page.css';
|
||||
|
||||
export default function ChatPage() {
|
||||
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
|
||||
const createConversation = useChatStore((s) => s.createConversation);
|
||||
const selectedNotebookId = useChatStore((s) => s.selectedNotebookId);
|
||||
const selectedNotebookMeta = useChatStore((s) => s.selectedNotebookMeta);
|
||||
|
||||
// Build notebook override for the streaming hook
|
||||
const notebookOverride = selectedNotebookId && selectedNotebookMeta
|
||||
? {
|
||||
notebook_id: selectedNotebookId,
|
||||
title: selectedNotebookMeta.title,
|
||||
engine: selectedNotebookMeta.engine,
|
||||
}
|
||||
: null;
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
abort,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
} = useChatStream({ notebookOverride });
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true);
|
||||
|
||||
const activeConv = getActiveConversation();
|
||||
const messages = activeConv?.messages ?? [];
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const question = input.trim();
|
||||
if (!question || streaming) return;
|
||||
setInput('');
|
||||
sendMessage(question);
|
||||
}, [input, streaming, sendMessage]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (streaming) abort();
|
||||
createConversation();
|
||||
setSidebarOpen(false);
|
||||
setInput('');
|
||||
}, [createConversation, streaming, abort]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="chat-page-root">
|
||||
{/* Header */}
|
||||
<header className="chat-page-header">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile sidebar trigger */}
|
||||
<button
|
||||
className="chat-page-menu-btn md:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
{/* Desktop sidebar toggle */}
|
||||
<button
|
||||
className="chat-page-menu-btn hidden md:flex"
|
||||
onClick={() => setDesktopSidebarVisible(!desktopSidebarVisible)}
|
||||
aria-label={desktopSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||
>
|
||||
{desktopSidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
|
||||
</button>
|
||||
<a href="/" className="chat-page-logo">
|
||||
<Zap size={18} className="text-[var(--color-sb-accent)]" />
|
||||
<span>SpiceBook</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="chat-page-header-title">
|
||||
{activeConv?.title ?? 'Circuit Assistant'}
|
||||
</div>
|
||||
<div className="w-10" /> {/* Spacer for centering */}
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="chat-page-body">
|
||||
{/* Desktop sidebar */}
|
||||
{desktopSidebarVisible && (
|
||||
<aside className="chat-page-desktop-sidebar">
|
||||
<ChatSidebar onNewConversation={handleNewConversation} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar (Sheet drawer) */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="p-0 w-[300px]">
|
||||
<SheetTitle className="sr-only">Conversations</SheetTitle>
|
||||
<ChatSidebar onNewConversation={handleNewConversation} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Message area */}
|
||||
<main className="chat-page-main">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
statusText={statusText}
|
||||
reasoningText={reasoningText}
|
||||
reasoningTime={reasoningTime}
|
||||
/>
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
onAbort={abort}
|
||||
streaming={streaming}
|
||||
notebookTitle={selectedNotebookMeta?.title}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/chat/ChatSidebar.tsx
Normal file
111
frontend/src/components/chat/ChatSidebar.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, MessageSquare, Trash2, Search } from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Separator } from '../ui/separator';
|
||||
import NotebookPicker from './NotebookPicker';
|
||||
|
||||
interface ChatSidebarProps {
|
||||
onNewConversation: () => void;
|
||||
}
|
||||
|
||||
export default function ChatSidebar({ onNewConversation }: ChatSidebarProps) {
|
||||
const conversations = useChatStore((s) => s.conversations);
|
||||
const activeId = useChatStore((s) => s.activeConversationId);
|
||||
const setActive = useChatStore((s) => s.setActiveConversation);
|
||||
const deleteConv = useChatStore((s) => s.deleteConversation);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const filtered = search.trim()
|
||||
? conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: conversations;
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (pendingDeleteId === id) {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(null);
|
||||
deleteConv(id);
|
||||
} else {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(id);
|
||||
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
|
||||
}
|
||||
},
|
||||
[pendingDeleteId, deleteConv],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="chat-page-sidebar">
|
||||
{/* New conversation */}
|
||||
<div className="chat-page-sidebar-header">
|
||||
<button className="chat-page-new-btn" onClick={onNewConversation}>
|
||||
<Plus size={14} />
|
||||
<span>New conversation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="chat-page-sidebar-search">
|
||||
<Search size={14} className="text-[var(--color-sb-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="chat-page-sidebar-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="chat-page-conv-list">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--color-sb-muted)]">
|
||||
{search ? 'No matching conversations' : 'No conversations yet'}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chat-page-conv-item ${c.id === activeId ? 'active' : ''}`}
|
||||
onClick={() => setActive(c.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm">{c.title}</div>
|
||||
<div className="text-xs text-[var(--color-sb-muted)]">
|
||||
{c.messages.length} message{c.messages.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`chat-page-conv-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(c.id, e)}
|
||||
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete'}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notebook picker */}
|
||||
<NotebookPicker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,70 +7,15 @@ import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import type { ChatMessage } from '../../lib/chat-store';
|
||||
import { streamChat } from '../../lib/chat-api';
|
||||
import { useNotebookStore } from '../../lib/notebook-store';
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { renderMarkdown } from '../../lib/chat-render';
|
||||
import { useChatStream } from '../../lib/use-chat-stream';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import '../../styles/chat-widget.css';
|
||||
|
||||
// ── Markdown rendering (marked + KaTeX + DOMPurify) ──────
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
marked.use(markedKatex({
|
||||
throwOnError: false,
|
||||
nonStandard: true,
|
||||
}));
|
||||
|
||||
// KaTeX generates SVG + spans with specific classes/attributes.
|
||||
// DOMPurify must allow these through.
|
||||
const KATEX_TAGS = [
|
||||
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub',
|
||||
'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot',
|
||||
'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose',
|
||||
'annotation', 'annotation-xml',
|
||||
];
|
||||
|
||||
const KATEX_ATTRS = [
|
||||
'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel',
|
||||
'fence', 'stretchy', 'symmetric', 'lspace', 'rspace',
|
||||
'linethickness', 'columnalign', 'rowalign', 'columnspacing',
|
||||
'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing',
|
||||
'width', 'height', 'voffset', 'accent', 'accentunder',
|
||||
'notation', 'minsize', 'maxsize', 'movablelimits',
|
||||
'aria-hidden', 'focusable', 'role', 'tabindex',
|
||||
'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke',
|
||||
'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
||||
'transform', 'clip-path',
|
||||
];
|
||||
|
||||
// LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext")
|
||||
// but marked-katex-extension requires $$ on its own line for display mode.
|
||||
// This normalizer ensures $$ delimiters get their own lines.
|
||||
function normalizeDisplayMath(text: string): string {
|
||||
return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => {
|
||||
const trimmed = inner.trim();
|
||||
return `\n$$\n${trimmed}\n$$\n`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
const normalized = normalizeDisplayMath(text);
|
||||
const raw = marked.parse(normalized, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ADD_TAGS: KATEX_TAGS,
|
||||
ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Components ──────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
|
||||
@ -163,36 +108,25 @@ function HistoryView({
|
||||
|
||||
export default function ChatWidget() {
|
||||
const panelOpen = useChatStore((s) => s.panelOpen);
|
||||
const streaming = useChatStore((s) => s.streaming);
|
||||
const togglePanel = useChatStore((s) => s.togglePanel);
|
||||
const closePanel = useChatStore((s) => s.closePanel);
|
||||
const createConversation = useChatStore((s) => s.createConversation);
|
||||
const addUserMessage = useChatStore((s) => s.addUserMessage);
|
||||
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
|
||||
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
|
||||
const setStreaming = useChatStore((s) => s.setStreaming);
|
||||
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
|
||||
const activeConversationId = useChatStore((s) => s.activeConversationId);
|
||||
|
||||
// Notebook context from the notebook editor (if on a notebook page)
|
||||
const notebookId = useNotebookStore((s) => s.notebookId);
|
||||
const notebook = useNotebookStore((s) => s.notebook);
|
||||
const {
|
||||
sendMessage,
|
||||
abort,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
} = useChatStream();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [reasoningText, setReasoningText] = useState('');
|
||||
const [reasoningTime, setReasoningTime] = useState(0);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Token batching: accumulate tokens in a ref, flush to state once per animation frame.
|
||||
// Without this, React 19 batches all rapid set() calls from the SSE loop into one
|
||||
// render at stream end — so the user sees nothing until the LLM finishes.
|
||||
const pendingTokensRef = useRef('');
|
||||
const flushRafRef = useRef(0);
|
||||
|
||||
const activeConv = getActiveConversation();
|
||||
const messages = activeConv?.messages ?? [];
|
||||
@ -228,97 +162,9 @@ export default function ChatWidget() {
|
||||
const handleSend = useCallback(async () => {
|
||||
const question = input.trim();
|
||||
if (!question || streaming) return;
|
||||
|
||||
// Ensure there's an active conversation
|
||||
if (!activeConversationId) {
|
||||
createConversation();
|
||||
}
|
||||
|
||||
setInput('');
|
||||
addUserMessage(question);
|
||||
addAssistantMessage('');
|
||||
setStreaming(true);
|
||||
setStatusText('');
|
||||
setReasoningText('');
|
||||
setReasoningTime(0);
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
const reasoningStart = Date.now();
|
||||
|
||||
try {
|
||||
const notebookCtx = notebookId && notebook
|
||||
? {
|
||||
notebook_id: notebookId,
|
||||
title: notebook.metadata.title,
|
||||
engine: notebook.metadata.engine,
|
||||
}
|
||||
: null;
|
||||
|
||||
for await (const evt of streamChat({
|
||||
question,
|
||||
notebook: notebookCtx,
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
switch (evt.event) {
|
||||
case 'status':
|
||||
setStatusText((evt.data as { text: string }).text);
|
||||
break;
|
||||
case 'token':
|
||||
// Accumulate in ref; flush to Zustand once per animation frame
|
||||
pendingTokensRef.current += (evt.data as { text: string }).text;
|
||||
if (!flushRafRef.current) {
|
||||
flushRafRef.current = requestAnimationFrame(() => {
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
flushRafRef.current = 0;
|
||||
});
|
||||
setStatusText('');
|
||||
}
|
||||
break;
|
||||
case 'reasoning':
|
||||
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
|
||||
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
|
||||
break;
|
||||
case 'error':
|
||||
appendToLastAssistant(
|
||||
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
|
||||
);
|
||||
break;
|
||||
case 'done':
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
// User cancelled — that's fine
|
||||
} else {
|
||||
appendToLastAssistant(
|
||||
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Flush any remaining buffered tokens
|
||||
if (flushRafRef.current) {
|
||||
cancelAnimationFrame(flushRafRef.current);
|
||||
flushRafRef.current = 0;
|
||||
}
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
setStreaming(false);
|
||||
setStatusText('');
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [
|
||||
input, streaming, activeConversationId, notebookId, notebook,
|
||||
createConversation, addUserMessage, addAssistantMessage,
|
||||
appendToLastAssistant, setStreaming,
|
||||
]);
|
||||
await sendMessage(question);
|
||||
}, [input, streaming, sendMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@ -331,18 +177,17 @@ export default function ChatWidget() {
|
||||
);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
// If streaming, abort first
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
setStreaming(false);
|
||||
if (streaming) {
|
||||
abort();
|
||||
}
|
||||
createConversation();
|
||||
setShowHistory(false);
|
||||
setReasoningText('');
|
||||
setReasoningTime(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}, [createConversation, setStreaming]);
|
||||
}, [createConversation, streaming, abort]);
|
||||
|
||||
const handlePopOut = useCallback(() => {
|
||||
window.open('/chat', '_blank');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -367,6 +212,13 @@ export default function ChatWidget() {
|
||||
? 'Conversations'
|
||||
: activeConv?.title ?? 'Circuit Assistant'}
|
||||
</span>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={handlePopOut}
|
||||
title="Open full chat page"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={handleNewConversation}
|
||||
@ -430,11 +282,7 @@ export default function ChatWidget() {
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
notebookId
|
||||
? 'Ask about this circuit…'
|
||||
: 'Ask a circuit question…'
|
||||
}
|
||||
placeholder="Ask a circuit question…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
97
frontend/src/components/chat/NotebookPicker.tsx
Normal file
97
frontend/src/components/chat/NotebookPicker.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command';
|
||||
import { listNotebooks } from '../../lib/api';
|
||||
import type { NotebookSummary } from '../../lib/types';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
|
||||
export default function NotebookPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const selectedId = useChatStore((s) => s.selectedNotebookId);
|
||||
const selectedMeta = useChatStore((s) => s.selectedNotebookMeta);
|
||||
const setSelected = useChatStore((s) => s.setSelectedNotebook);
|
||||
const clearSelected = useChatStore((s) => s.clearSelectedNotebook);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
listNotebooks()
|
||||
.then(setNotebooks)
|
||||
.catch(() => setNotebooks([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(nb: NotebookSummary) => {
|
||||
setSelected(nb.id, { title: nb.title, engine: nb.engine });
|
||||
setOpen(false);
|
||||
},
|
||||
[setSelected],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
clearSelected();
|
||||
},
|
||||
[clearSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-page-notebook-picker">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="chat-page-notebook-btn">
|
||||
<BookOpen size={14} />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{selectedMeta ? selectedMeta.title : 'Select notebook…'}
|
||||
</span>
|
||||
{selectedId && (
|
||||
<span
|
||||
className="chat-page-notebook-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start" side="top">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search notebooks…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? 'Loading…' : 'No notebooks found'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{notebooks.map((nb) => (
|
||||
<CommandItem
|
||||
key={nb.id}
|
||||
value={`${nb.title} ${nb.id}`}
|
||||
onSelect={() => handleSelect(nb)}
|
||||
>
|
||||
<BookOpen size={14} className="shrink-0 text-[var(--color-sb-muted)]" />
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
<span className="truncate text-sm">{nb.title}</span>
|
||||
<span className="text-xs text-[var(--color-sb-muted)]">
|
||||
{nb.engine} · {nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{nb.id === selectedId && (
|
||||
<span className="ml-auto text-[var(--color-sb-accent)] text-xs">✓</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/ui/command.tsx
Normal file
111
frontend/src/components/ui/command.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-[var(--color-sb-surface)] text-[var(--color-sb-text)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-[var(--color-sb-border)] px-3" cmdk-input-wrapper="">
|
||||
<Search size={14} className="mr-2 shrink-0 text-[var(--color-sb-muted)]" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-[var(--color-sb-muted)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-[var(--color-sb-muted)]" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-[var(--color-sb-text)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[var(--color-sb-muted)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none',
|
||||
'data-[selected=true]:bg-[var(--color-sb-cell)] data-[selected=true]:text-[var(--color-sb-text-bright)]',
|
||||
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-[var(--color-sb-border)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
};
|
||||
32
frontend/src/components/ui/popover.tsx
Normal file
32
frontend/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-lg border border-[var(--color-sb-border)] bg-[var(--color-sb-surface)] p-4 shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
43
frontend/src/components/ui/scroll-area.tsx
Normal file
43
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[var(--color-sb-border)] hover:bg-[var(--color-sb-muted)]" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
23
frontend/src/components/ui/separator.tsx
Normal file
23
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-[var(--color-sb-border)]',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
88
frontend/src/components/ui/sheet.tsx
Normal file
88
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'left', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-[var(--color-sb-bg)] shadow-lg transition ease-in-out',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-300 data-[state=closed]:duration-200',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 max-w-[320px] border-r border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 max-w-[320px] border-l border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 border-b border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 border-t border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 text-[var(--color-sb-muted)] hover:text-[var(--color-sb-text)]">
|
||||
<X size={16} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-2 px-4 pt-4', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold text-[var(--color-sb-text-bright)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
};
|
||||
31
frontend/src/components/ui/tooltip.tsx
Normal file
31
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-[var(--color-sb-cell)] px-3 py-1.5 text-xs text-[var(--color-sb-text)]',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
67
frontend/src/layouts/ChatLayout.astro
Normal file
67
frontend/src/layouts/ChatLayout.astro
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
import { Seo } from 'astro-seo-meta';
|
||||
import type { SEOProps } from '../lib/seo';
|
||||
import { SEO_DEFAULTS } from '../lib/seo';
|
||||
|
||||
interface Props extends SEOProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Chat | SpiceBook',
|
||||
description,
|
||||
ogImage,
|
||||
ogType = 'website',
|
||||
noindex = false,
|
||||
canonicalPath,
|
||||
} = Astro.props;
|
||||
|
||||
const pageTitle = title === 'SpiceBook' ? title : `${title} | SpiceBook`;
|
||||
const pageDescription = description || SEO_DEFAULTS.defaultDescription;
|
||||
const ogImageUrl = `${SEO_DEFAULTS.siteUrl}${ogImage || SEO_DEFAULTS.defaultOgImage}`;
|
||||
const canonicalUrl = canonicalPath
|
||||
? `${SEO_DEFAULTS.siteUrl}${canonicalPath}`
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||
<Seo
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon="/favicon.svg"
|
||||
colorScheme="dark"
|
||||
robots={noindex ? 'noindex, nofollow' : 'index, follow'}
|
||||
facebook={{
|
||||
image: ogImageUrl,
|
||||
url: canonicalUrl || SEO_DEFAULTS.siteUrl,
|
||||
type: ogType,
|
||||
}}
|
||||
twitter={{
|
||||
image: ogImageUrl,
|
||||
card: 'summary_large_image',
|
||||
}}
|
||||
/>
|
||||
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
|
||||
<slot />
|
||||
<script>
|
||||
// Clean up stale service workers — SpiceBook doesn't use one
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
||||
for (const reg of regs) reg.unregister();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import '../styles/globals.css';
|
||||
</style>
|
||||
68
frontend/src/lib/chat-render.ts
Normal file
68
frontend/src/lib/chat-render.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared markdown + KaTeX rendering for SpiceBook chat.
|
||||
* Used by both the floating ChatWidget and the full /chat page.
|
||||
*/
|
||||
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// ── Configure marked once at module load ─────────────────
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
marked.use(markedKatex({
|
||||
throwOnError: false,
|
||||
nonStandard: true,
|
||||
}));
|
||||
|
||||
// ── KaTeX allow-lists for DOMPurify ─────────────────────
|
||||
|
||||
export const KATEX_TAGS = [
|
||||
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub',
|
||||
'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot',
|
||||
'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose',
|
||||
'annotation', 'annotation-xml',
|
||||
];
|
||||
|
||||
export const KATEX_ATTRS = [
|
||||
'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel',
|
||||
'fence', 'stretchy', 'symmetric', 'lspace', 'rspace',
|
||||
'linethickness', 'columnalign', 'rowalign', 'columnspacing',
|
||||
'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing',
|
||||
'width', 'height', 'voffset', 'accent', 'accentunder',
|
||||
'notation', 'minsize', 'maxsize', 'movablelimits',
|
||||
'aria-hidden', 'focusable', 'role', 'tabindex',
|
||||
'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke',
|
||||
'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
||||
'transform', 'clip-path',
|
||||
];
|
||||
|
||||
// ── Rendering functions ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext")
|
||||
* but marked-katex-extension requires $$ on its own line for display mode.
|
||||
* This normalizer ensures $$ delimiters get their own lines.
|
||||
*/
|
||||
export function normalizeDisplayMath(text: string): string {
|
||||
return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => {
|
||||
const trimmed = inner.trim();
|
||||
return `\n$$\n${trimmed}\n$$\n`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML with KaTeX support.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
const normalized = normalizeDisplayMath(text);
|
||||
const raw = marked.parse(normalized, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ADD_TAGS: KATEX_TAGS,
|
||||
ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
|
||||
});
|
||||
}
|
||||
@ -23,11 +23,18 @@ export interface Conversation {
|
||||
const MAX_CONVERSATIONS = 20;
|
||||
const MAX_MESSAGES = 50;
|
||||
|
||||
export interface SelectedNotebookMeta {
|
||||
title: string;
|
||||
engine: string;
|
||||
}
|
||||
|
||||
interface ChatStore {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
panelOpen: boolean;
|
||||
streaming: boolean;
|
||||
selectedNotebookId: string | null;
|
||||
selectedNotebookMeta: SelectedNotebookMeta | null;
|
||||
|
||||
// Actions
|
||||
openPanel: () => void;
|
||||
@ -41,6 +48,8 @@ interface ChatStore {
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
getActiveConversation: () => Conversation | null;
|
||||
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) => void;
|
||||
clearSelectedNotebook: () => void;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
@ -59,6 +68,8 @@ export const useChatStore = create<ChatStore>()(
|
||||
activeConversationId: null,
|
||||
panelOpen: false,
|
||||
streaming: false,
|
||||
selectedNotebookId: null,
|
||||
selectedNotebookMeta: null,
|
||||
|
||||
openPanel: () => set({ panelOpen: true }),
|
||||
closePanel: () => set({ panelOpen: false }),
|
||||
@ -158,12 +169,20 @@ export const useChatStore = create<ChatStore>()(
|
||||
const s = get();
|
||||
return s.conversations.find((c) => c.id === s.activeConversationId) ?? null;
|
||||
},
|
||||
|
||||
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) =>
|
||||
set({ selectedNotebookId: id, selectedNotebookMeta: meta }),
|
||||
|
||||
clearSelectedNotebook: () =>
|
||||
set({ selectedNotebookId: null, selectedNotebookMeta: null }),
|
||||
}),
|
||||
{
|
||||
name: 'spicebook-chat',
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
activeConversationId: state.activeConversationId,
|
||||
selectedNotebookId: state.selectedNotebookId,
|
||||
selectedNotebookMeta: state.selectedNotebookMeta,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
176
frontend/src/lib/use-chat-stream.ts
Normal file
176
frontend/src/lib/use-chat-stream.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Shared streaming hook for SpiceBook chat.
|
||||
* Handles SSE event dispatch, RAF token batching, abort, and reasoning state.
|
||||
* Used by both the floating ChatWidget and the full /chat page.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useChatStore } from './chat-store';
|
||||
import { useNotebookStore } from './notebook-store';
|
||||
import { streamChat } from './chat-api';
|
||||
|
||||
export interface ChatStreamState {
|
||||
statusText: string;
|
||||
reasoningText: string;
|
||||
reasoningTime: number;
|
||||
}
|
||||
|
||||
export interface UseChatStreamReturn extends ChatStreamState {
|
||||
/** Send a message. Creates a conversation if none is active. */
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
/** Abort the current stream. */
|
||||
abort: () => void;
|
||||
/** Whether a stream is currently in progress. */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
interface UseChatStreamOptions {
|
||||
/** Override notebook context (for the /chat page's notebook picker). */
|
||||
notebookOverride?: {
|
||||
notebook_id: string;
|
||||
title: string;
|
||||
engine: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useChatStream(opts?: UseChatStreamOptions): UseChatStreamReturn {
|
||||
const streaming = useChatStore((s) => s.streaming);
|
||||
const activeConversationId = useChatStore((s) => s.activeConversationId);
|
||||
const createConversation = useChatStore((s) => s.createConversation);
|
||||
const addUserMessage = useChatStore((s) => s.addUserMessage);
|
||||
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
|
||||
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
|
||||
const setStreaming = useChatStore((s) => s.setStreaming);
|
||||
|
||||
// Notebook context from the notebook store (widget on notebook pages)
|
||||
const notebookId = useNotebookStore((s) => s.notebookId);
|
||||
const notebook = useNotebookStore((s) => s.notebook);
|
||||
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [reasoningText, setReasoningText] = useState('');
|
||||
const [reasoningTime, setReasoningTime] = useState(0);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Token batching: accumulate tokens in a ref, flush to state once per
|
||||
// animation frame. Without this, React 19 batches all rapid set() calls
|
||||
// from the SSE loop into one render at stream end — so the user sees
|
||||
// nothing until the LLM finishes.
|
||||
const pendingTokensRef = useRef('');
|
||||
const flushRafRef = useRef(0);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
setStreaming(false);
|
||||
}
|
||||
}, [setStreaming]);
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || streaming) return;
|
||||
|
||||
// Ensure there's an active conversation
|
||||
if (!activeConversationId) {
|
||||
createConversation();
|
||||
}
|
||||
|
||||
addUserMessage(question);
|
||||
addAssistantMessage('');
|
||||
setStreaming(true);
|
||||
setStatusText('');
|
||||
setReasoningText('');
|
||||
setReasoningTime(0);
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
const reasoningStart = Date.now();
|
||||
|
||||
try {
|
||||
// Notebook context: prefer explicit override (from picker), fall back
|
||||
// to the notebook store (when widget is on a notebook page)
|
||||
const notebookCtx = opts?.notebookOverride
|
||||
?? (notebookId && notebook
|
||||
? {
|
||||
notebook_id: notebookId,
|
||||
title: notebook.metadata.title,
|
||||
engine: notebook.metadata.engine,
|
||||
}
|
||||
: null);
|
||||
|
||||
for await (const evt of streamChat({
|
||||
question,
|
||||
notebook: notebookCtx,
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
switch (evt.event) {
|
||||
case 'status':
|
||||
setStatusText((evt.data as { text: string }).text);
|
||||
break;
|
||||
case 'token':
|
||||
// Accumulate in ref; flush to Zustand once per animation frame
|
||||
pendingTokensRef.current += (evt.data as { text: string }).text;
|
||||
if (!flushRafRef.current) {
|
||||
flushRafRef.current = requestAnimationFrame(() => {
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
flushRafRef.current = 0;
|
||||
});
|
||||
setStatusText('');
|
||||
}
|
||||
break;
|
||||
case 'reasoning':
|
||||
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
|
||||
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
|
||||
break;
|
||||
case 'error':
|
||||
appendToLastAssistant(
|
||||
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
|
||||
);
|
||||
break;
|
||||
case 'done':
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
// User cancelled — that's fine
|
||||
} else {
|
||||
appendToLastAssistant(
|
||||
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Flush any remaining buffered tokens
|
||||
if (flushRafRef.current) {
|
||||
cancelAnimationFrame(flushRafRef.current);
|
||||
flushRafRef.current = 0;
|
||||
}
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
setStreaming(false);
|
||||
setStatusText('');
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [
|
||||
streaming, activeConversationId, notebookId, notebook,
|
||||
opts?.notebookOverride,
|
||||
createConversation, addUserMessage, addAssistantMessage,
|
||||
appendToLastAssistant, setStreaming,
|
||||
]);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
abort,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
};
|
||||
}
|
||||
8
frontend/src/pages/chat.astro
Normal file
8
frontend/src/pages/chat.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import ChatLayout from '../layouts/ChatLayout.astro';
|
||||
import ChatPage from '../components/chat/ChatPage';
|
||||
---
|
||||
|
||||
<ChatLayout title="Chat" description="Full-page circuit assistant with conversation history and notebook context" canonicalPath="/chat">
|
||||
<ChatPage client:load />
|
||||
</ChatLayout>
|
||||
657
frontend/src/styles/chat-page.css
Normal file
657
frontend/src/styles/chat-page.css
Normal file
@ -0,0 +1,657 @@
|
||||
/* SpiceBook Full Chat Page — dark-only, full-viewport layout */
|
||||
|
||||
/* ── Root layout ─────────────────────────────────────── */
|
||||
|
||||
.chat-page-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-sb-bg);
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────── */
|
||||
|
||||
.chat-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3rem;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--color-sb-border);
|
||||
background: var(--color-sb-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-page-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-sb-text-bright);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-page-logo:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-page-header-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-sb-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 50%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-page-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-sb-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.chat-page-menu-btn:hover {
|
||||
background: var(--color-sb-cell);
|
||||
color: var(--color-sb-text);
|
||||
}
|
||||
|
||||
/* ── Body (sidebar + main) ───────────────────────────── */
|
||||
|
||||
.chat-page-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Desktop sidebar ─────────────────────────────────── */
|
||||
|
||||
.chat-page-desktop-sidebar {
|
||||
display: none;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--color-sb-border);
|
||||
background: var(--color-sb-surface);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat-page-desktop-sidebar {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ───────────────────────────────── */
|
||||
|
||||
.chat-page-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-page-sidebar-header {
|
||||
padding: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-page-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed var(--color-sb-border);
|
||||
background: transparent;
|
||||
color: var(--color-sb-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-page-new-btn:hover {
|
||||
background: var(--color-sb-cell);
|
||||
border-color: var(--color-sb-accent);
|
||||
color: var(--color-sb-text-bright);
|
||||
}
|
||||
|
||||
.chat-page-sidebar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0.75rem 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-sb-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-sb-bg);
|
||||
}
|
||||
|
||||
.chat-page-sidebar-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-sb-text);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.chat-page-sidebar-search-input::placeholder {
|
||||
color: var(--color-sb-muted);
|
||||
}
|
||||
|
||||
/* ── Conversation list ───────────────────────────────── */
|
||||
|
||||
.chat-page-conv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.chat-page-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-sb-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-page-conv-item:hover {
|
||||
background: var(--color-sb-cell);
|
||||
}
|
||||
|
||||
.chat-page-conv-item.active {
|
||||
background: var(--color-sb-cell);
|
||||
border-color: var(--color-sb-border);
|
||||
}
|
||||
|
||||
.chat-page-conv-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-sb-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.chat-page-conv-item:hover .chat-page-conv-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-page-conv-delete:hover {
|
||||
color: var(--color-sb-danger);
|
||||
}
|
||||
|
||||
.chat-page-conv-delete.confirm {
|
||||
color: var(--color-sb-danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Notebook picker ─────────────────────────────────── */
|
||||
|
||||
.chat-page-notebook-picker {
|
||||
padding: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-page-notebook-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--color-sb-border);
|
||||
background: var(--color-sb-bg);
|
||||
color: var(--color-sb-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.chat-page-notebook-btn:hover {
|
||||
border-color: var(--color-sb-accent);
|
||||
}
|
||||
|
||||
.chat-page-notebook-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
color: var(--color-sb-muted);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.chat-page-notebook-clear:hover {
|
||||
color: var(--color-sb-text-bright);
|
||||
background: var(--color-sb-cell);
|
||||
}
|
||||
|
||||
/* ── Main message area ───────────────────────────────── */
|
||||
|
||||
.chat-page-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Messages ────────────────────────────────────────── */
|
||||
|
||||
.chat-page-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chat-page-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-page-messages::-webkit-scrollbar-thumb {
|
||||
background: var(--color-sb-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-page-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-sb-muted);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.chat-page-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.chat-page-empty-icon {
|
||||
color: var(--color-sb-border);
|
||||
}
|
||||
|
||||
/* ── Message bubbles (wider than widget) ─────────────── */
|
||||
|
||||
.chat-page-bubble {
|
||||
max-width: 720px;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-page-bubble.user {
|
||||
align-self: flex-end;
|
||||
background: var(--color-sb-accent);
|
||||
color: white;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--color-sb-cell);
|
||||
color: var(--color-sb-text);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Assistant bubble content styling ────────────────── */
|
||||
|
||||
.chat-page-bubble.assistant strong {
|
||||
color: var(--color-sb-text-bright);
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125em;
|
||||
background: var(--color-sb-code-bg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant pre {
|
||||
background: var(--color-sb-code-bg);
|
||||
border: 1px solid var(--color-sb-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.chat-page-bubble.assistant h1,
|
||||
.chat-page-bubble.assistant h2,
|
||||
.chat-page-bubble.assistant h3,
|
||||
.chat-page-bubble.assistant h4 {
|
||||
color: var(--color-sb-text-bright);
|
||||
font-weight: 600;
|
||||
margin: 0.875rem 0 0.375rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant h1 { font-size: 1.125rem; }
|
||||
.chat-page-bubble.assistant h2 { font-size: 1.0625rem; }
|
||||
.chat-page-bubble.assistant h3 { font-size: 1rem; }
|
||||
.chat-page-bubble.assistant h4 { font-size: 0.9375rem; }
|
||||
|
||||
.chat-page-bubble.assistant h1:first-child,
|
||||
.chat-page-bubble.assistant h2:first-child,
|
||||
.chat-page-bubble.assistant h3:first-child,
|
||||
.chat-page-bubble.assistant h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.chat-page-bubble.assistant p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.chat-page-bubble.assistant ul,
|
||||
.chat-page-bubble.assistant ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.chat-page-bubble.assistant hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-sb-border);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.chat-page-bubble.assistant blockquote {
|
||||
border-left: 3px solid var(--color-sb-accent);
|
||||
margin: 0.625rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
color: var(--color-sb-text);
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant blockquote p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.chat-page-bubble.assistant table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.625rem 0;
|
||||
font-size: 0.8125rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant th,
|
||||
.chat-page-bubble.assistant td {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-sb-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant th {
|
||||
background: var(--color-sb-surface);
|
||||
color: var(--color-sb-text-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.chat-page-bubble.assistant a {
|
||||
color: var(--color-sb-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* KaTeX */
|
||||
.chat-page-bubble.assistant .katex {
|
||||
font-size: 1em;
|
||||
color: var(--color-sb-text-bright);
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant .katex-display {
|
||||
margin: 0.625rem 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.chat-page-bubble.assistant .katex-display > .katex {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* ── Status + reasoning ──────────────────────────────── */
|
||||
|
||||
.chat-page-status {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-sb-muted);
|
||||
padding: 0.25rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-page-reasoning {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-sb-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-page-reasoning summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-sb-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-page-reasoning summary:hover {
|
||||
color: var(--color-sb-text);
|
||||
}
|
||||
|
||||
.chat-page-reasoning-body {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-sb-surface);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-sb-border);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Streaming cursor ────────────────────────────────── */
|
||||
|
||||
.chat-page-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: var(--color-sb-accent);
|
||||
animation: chat-page-blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
@keyframes chat-page-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Input area ──────────────────────────────────────── */
|
||||
|
||||
.chat-page-input-container {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
border-top: 1px solid var(--color-sb-border);
|
||||
background: var(--color-sb-surface);
|
||||
}
|
||||
|
||||
.chat-page-input-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-sb-accent);
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-page-input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-page-textarea {
|
||||
flex: 1;
|
||||
background: var(--color-sb-bg);
|
||||
border: 1px solid var(--color-sb-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-sb-text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.15s;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
min-height: 2.5rem;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.chat-page-textarea::placeholder {
|
||||
color: var(--color-sb-muted);
|
||||
}
|
||||
|
||||
.chat-page-textarea:focus {
|
||||
border-color: var(--color-sb-accent);
|
||||
}
|
||||
|
||||
.chat-page-send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: var(--color-sb-accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-page-send-btn:hover:not(:disabled) {
|
||||
background: var(--color-sb-accent-hover);
|
||||
}
|
||||
|
||||
.chat-page-send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-page-send-btn.abort {
|
||||
background: var(--color-sb-danger);
|
||||
}
|
||||
|
||||
.chat-page-send-btn.abort:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.chat-page-input-hint {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-sb-muted);
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.chat-page-messages {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-page-bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-page-input-container {
|
||||
padding: 0.625rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.chat-page-input-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wider screens — center messages with max width */
|
||||
@media (min-width: 1200px) {
|
||||
.chat-page-messages {
|
||||
padding-left: calc((100% - 800px) / 2);
|
||||
padding-right: calc((100% - 800px) / 2);
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,33 @@
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* shadcn-ui CSS variable mappings — mapped to SpiceBook's dark slate theme.
|
||||
These are consumed by Radix primitives and shadcn components.
|
||||
We intentionally avoid re-declaring a full light/dark toggle since SpiceBook
|
||||
is dark-only — we just wire the shadcn variable names to our tokens. */
|
||||
:root {
|
||||
--background: var(--color-sb-bg);
|
||||
--foreground: var(--color-sb-text);
|
||||
--card: var(--color-sb-surface);
|
||||
--card-foreground: var(--color-sb-text);
|
||||
--popover: var(--color-sb-surface);
|
||||
--popover-foreground: var(--color-sb-text);
|
||||
--primary: var(--color-sb-accent);
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--color-sb-cell);
|
||||
--secondary-foreground: var(--color-sb-text);
|
||||
--muted: var(--color-sb-cell);
|
||||
--muted-foreground: var(--color-sb-muted);
|
||||
--accent: var(--color-sb-cell);
|
||||
--accent-foreground: var(--color-sb-text-bright);
|
||||
--destructive: var(--color-sb-danger);
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: var(--color-sb-border);
|
||||
--input: var(--color-sb-border);
|
||||
--ring: var(--color-sb-accent);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Waveform canvas colors (read by JS via getComputedStyle) */
|
||||
:root {
|
||||
--color-wf-axis: #475569;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user