phase4: full chat UI — shell, sidebar, transcript, model menu, input bar, settings, first-run; responsive + light/dark verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 22 changed files with +6,539 and −69
modified
docs/PLAN.md
+19 −1
@@ -92,7 +92,25 @@ with WebCrypto AES-GCM and is honestly framed. Gate ran in a real browser with | ||
| 92 | 92 | a persistent profile: reload AND restart survival, exact export round-trip, and |
| 93 | 93 | vault behavior all green. |
| 94 | 94 | |
| 95 | −## Phase 4 — Design system & UI — pending | |
| 95 | +## Phase 4 — Design system & UI | |
| 96 | + | |
| 97 | +- [x] 4.1 Light flagship theme via tokens (Phase 1 CSS vars) — light default, dark opt-in, 5 accents, reduced-motion respected | |
| 98 | +- [x] 4.2 Shell: sidebar (wordmark, search ⌘F, New Chat ⌘N, Pinned/Today/Yesterday/7 Days/Older groups, hover pin/delete, usage footer) + chat area (header 52px with editable title, centered model chip, compare/export/info actions) | |
| 99 | +- [x] 4.2.1 Model Menu: ⌘K command-palette style — fuzzy search, capability filter chips (Vision/Tools/Reasoning/JSON/Long context/Cheapest), Aliases + Favorites + Recents groups, keyed-providers-first sections, capability/context/price badges, star favorites, "Add key" affordance, keyboard nav (↑↓/↵/⌘↵ default/esc), footer hints | |
| 100 | +- [x] Transcript: centered 760px column, user right accent-subtle / assistant left surface+avatar, GFM markdown + tables + zebra rows + syntax-highlighted code (language label, copy, wrap), collapsible reasoning, citation chips, streaming caret, scroll-lock + jump-to-latest pill, hover metadata (time, tokens, cost) + actions (copy/edit-resend/regenerate/quote/branch/read-aloud/delete), variant switcher | |
| 101 | +- [x] Input bar: floating card, auto-grow, image+text-file attach (click/drag/paste), thumbnails, params popover (support-gated controls with enable checkboxes), circular send ⌘↩ / Stop, context-usage bar + token/cost HUD | |
| 102 | +- [x] Settings modal: Providers & Keys (masked, Test + latency + status dot, per-provider proxy URL, honesty notice), Models (catalog browser, favorites, set-default), Appearance (theme/accent/font-size/density + live preview), Data (export incl.-keys warning / import / confirm-twice clear), Advanced (default system prompt, passphrase lock, streaming toggle) | |
| 103 | +- [x] First-run honest welcome + root empty state (glyph, greeting, model chip, 4 suggestion cards — self-contained ones send immediately, input-expecting ones seed the draft) | |
| 104 | +- [x] Toasts with undo (conversation delete) | |
| 105 | +- [x] 4.3 Responsive: sidebar drawer <768px with scrim, full-width transcript, code blocks scroll internally, no horizontal overflow; motion tokens (80ms hover, 150ms message-in, blinking caret) | |
| 106 | +- [x] 4.4 Quality gate: screenshotted desktop+phone, light+dark, model menu, settings — reviewed and fixed (filter-chip clipping, cost precision, draft seeding) | |
| 107 | + | |
| 108 | +**Phase 4 summary.** The full chat UI is live and matches the family design: | |
| 109 | +tokens-only styling, light flagship default, dark derived, responsive down to | |
| 110 | +phone width with a drawer sidebar. The Model Menu is the headline surface — | |
| 111 | +fuzzy-searchable, filterable, priced, favorited, keyboard-driven. Verified | |
| 112 | +visually in headless Chromium at 1240px and 390px in both themes; found and | |
| 113 | +fixed three issues in the review pass. `npm run check` green. | |
| 96 | 114 | |
| 97 | 115 | ## Phase 5 — Brand assets — pending |
| 98 | 116 | |
modified
package-lock.json
+1599 −49
@@ -8,8 +8,12 @@ | ||
| 8 | 8 | "name": "zyquo-cloud-web", |
| 9 | 9 | "version": "1.0.0", |
| 10 | 10 | "dependencies": { |
| 11 | + "highlight.js": "^11.11.1", | |
| 11 | 12 | "react": "^19.1.0", |
| 12 | 13 | "react-dom": "^19.1.0", |
| 14 | + "react-markdown": "^10.1.0", | |
| 15 | + "rehype-highlight": "^7.0.2", | |
| 16 | + "remark-gfm": "^4.0.1", | |
| 13 | 17 | "zustand": "^5.0.6" |
| 14 | 18 | }, |
| 15 | 19 | "devDependencies": { |
@@ -3011,13 +3015,39 @@ | ||
| 3011 | 3015 | "@babel/types": "^7.28.2" |
| 3012 | 3016 | } |
| 3013 | 3017 | }, |
| 3018 | + "node_modules/@types/debug": { | |
| 3019 | + "version": "4.1.13", | |
| 3020 | + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", | |
| 3021 | + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", | |
| 3022 | + "license": "MIT", | |
| 3023 | + "dependencies": { | |
| 3024 | + "@types/ms": "*" | |
| 3025 | + } | |
| 3026 | + }, | |
| 3014 | 3027 | "node_modules/@types/estree": { |
| 3015 | 3028 | "version": "1.0.9", |
| 3016 | 3029 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", |
| 3017 | 3030 | "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", |
| 3018 | − "dev": true, | |
| 3019 | 3031 | "license": "MIT" |
| 3020 | 3032 | }, |
| 3033 | + "node_modules/@types/estree-jsx": { | |
| 3034 | + "version": "1.0.5", | |
| 3035 | + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", | |
| 3036 | + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", | |
| 3037 | + "license": "MIT", | |
| 3038 | + "dependencies": { | |
| 3039 | + "@types/estree": "*" | |
| 3040 | + } | |
| 3041 | + }, | |
| 3042 | + "node_modules/@types/hast": { | |
| 3043 | + "version": "3.0.5", | |
| 3044 | + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", | |
| 3045 | + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", | |
| 3046 | + "license": "MIT", | |
| 3047 | + "dependencies": { | |
| 3048 | + "@types/unist": "*" | |
| 3049 | + } | |
| 3050 | + }, | |
| 3021 | 3051 | "node_modules/@types/json-schema": { |
| 3022 | 3052 | "version": "7.0.15", |
| 3023 | 3053 | "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", |
@@ -3025,11 +3055,25 @@ | ||
| 3025 | 3055 | "dev": true, |
| 3026 | 3056 | "license": "MIT" |
| 3027 | 3057 | }, |
| 3058 | + "node_modules/@types/mdast": { | |
| 3059 | + "version": "4.0.4", | |
| 3060 | + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", | |
| 3061 | + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", | |
| 3062 | + "license": "MIT", | |
| 3063 | + "dependencies": { | |
| 3064 | + "@types/unist": "*" | |
| 3065 | + } | |
| 3066 | + }, | |
| 3067 | + "node_modules/@types/ms": { | |
| 3068 | + "version": "2.1.0", | |
| 3069 | + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", | |
| 3070 | + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", | |
| 3071 | + "license": "MIT" | |
| 3072 | + }, | |
| 3028 | 3073 | "node_modules/@types/react": { |
| 3029 | 3074 | "version": "19.2.18", |
| 3030 | 3075 | "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", |
| 3031 | 3076 | "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", |
| 3032 | − "devOptional": true, | |
| 3033 | 3077 | "license": "MIT", |
| 3034 | 3078 | "dependencies": { |
| 3035 | 3079 | "csstype": "^3.2.2" |
@@ -3059,6 +3103,12 @@ | ||
| 3059 | 3103 | "dev": true, |
| 3060 | 3104 | "license": "MIT" |
| 3061 | 3105 | }, |
| 3106 | + "node_modules/@types/unist": { | |
| 3107 | + "version": "3.0.3", | |
| 3108 | + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", | |
| 3109 | + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", | |
| 3110 | + "license": "MIT" | |
| 3111 | + }, | |
| 3062 | 3112 | "node_modules/@typescript-eslint/eslint-plugin": { |
| 3063 | 3113 | "version": "8.65.0", |
| 3064 | 3114 | "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", |
@@ -3292,6 +3342,12 @@ | ||
| 3292 | 3342 | "url": "https://opencollective.com/eslint" |
| 3293 | 3343 | } |
| 3294 | 3344 | }, |
| 3345 | + "node_modules/@ungap/structured-clone": { | |
| 3346 | + "version": "1.3.3", | |
| 3347 | + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", | |
| 3348 | + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", | |
| 3349 | + "license": "ISC" | |
| 3350 | + }, | |
| 3295 | 3351 | "node_modules/@vitejs/plugin-react": { |
| 3296 | 3352 | "version": "4.7.0", |
| 3297 | 3353 | "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", |
@@ -3510,6 +3566,16 @@ | ||
| 3510 | 3566 | "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" |
| 3511 | 3567 | } |
| 3512 | 3568 | }, |
| 3569 | + "node_modules/bail": { | |
| 3570 | + "version": "2.0.2", | |
| 3571 | + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", | |
| 3572 | + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", | |
| 3573 | + "license": "MIT", | |
| 3574 | + "funding": { | |
| 3575 | + "type": "github", | |
| 3576 | + "url": "https://github.com/sponsors/wooorm" | |
| 3577 | + } | |
| 3578 | + }, | |
| 3513 | 3579 | "node_modules/balanced-match": { |
| 3514 | 3580 | "version": "4.0.4", |
| 3515 | 3581 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", |
@@ -3668,6 +3734,16 @@ | ||
| 3668 | 3734 | ], |
| 3669 | 3735 | "license": "CC-BY-4.0" |
| 3670 | 3736 | }, |
| 3737 | + "node_modules/ccount": { | |
| 3738 | + "version": "2.0.1", | |
| 3739 | + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", | |
| 3740 | + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", | |
| 3741 | + "license": "MIT", | |
| 3742 | + "funding": { | |
| 3743 | + "type": "github", | |
| 3744 | + "url": "https://github.com/sponsors/wooorm" | |
| 3745 | + } | |
| 3746 | + }, | |
| 3671 | 3747 | "node_modules/chalk": { |
| 3672 | 3748 | "version": "4.1.2", |
| 3673 | 3749 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", |
@@ -3685,6 +3761,46 @@ | ||
| 3685 | 3761 | "url": "https://github.com/chalk/chalk?sponsor=1" |
| 3686 | 3762 | } |
| 3687 | 3763 | }, |
| 3764 | + "node_modules/character-entities": { | |
| 3765 | + "version": "2.0.2", | |
| 3766 | + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", | |
| 3767 | + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", | |
| 3768 | + "license": "MIT", | |
| 3769 | + "funding": { | |
| 3770 | + "type": "github", | |
| 3771 | + "url": "https://github.com/sponsors/wooorm" | |
| 3772 | + } | |
| 3773 | + }, | |
| 3774 | + "node_modules/character-entities-html4": { | |
| 3775 | + "version": "2.1.0", | |
| 3776 | + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", | |
| 3777 | + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", | |
| 3778 | + "license": "MIT", | |
| 3779 | + "funding": { | |
| 3780 | + "type": "github", | |
| 3781 | + "url": "https://github.com/sponsors/wooorm" | |
| 3782 | + } | |
| 3783 | + }, | |
| 3784 | + "node_modules/character-entities-legacy": { | |
| 3785 | + "version": "3.0.0", | |
| 3786 | + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", | |
| 3787 | + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", | |
| 3788 | + "license": "MIT", | |
| 3789 | + "funding": { | |
| 3790 | + "type": "github", | |
| 3791 | + "url": "https://github.com/sponsors/wooorm" | |
| 3792 | + } | |
| 3793 | + }, | |
| 3794 | + "node_modules/character-reference-invalid": { | |
| 3795 | + "version": "2.0.1", | |
| 3796 | + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", | |
| 3797 | + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", | |
| 3798 | + "license": "MIT", | |
| 3799 | + "funding": { | |
| 3800 | + "type": "github", | |
| 3801 | + "url": "https://github.com/sponsors/wooorm" | |
| 3802 | + } | |
| 3803 | + }, | |
| 3688 | 3804 | "node_modules/color-convert": { |
| 3689 | 3805 | "version": "2.0.1", |
| 3690 | 3806 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", |
@@ -3705,6 +3821,16 @@ | ||
| 3705 | 3821 | "dev": true, |
| 3706 | 3822 | "license": "MIT" |
| 3707 | 3823 | }, |
| 3824 | + "node_modules/comma-separated-tokens": { | |
| 3825 | + "version": "2.0.3", | |
| 3826 | + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", | |
| 3827 | + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", | |
| 3828 | + "license": "MIT", | |
| 3829 | + "funding": { | |
| 3830 | + "type": "github", | |
| 3831 | + "url": "https://github.com/sponsors/wooorm" | |
| 3832 | + } | |
| 3833 | + }, | |
| 3708 | 3834 | "node_modules/commander": { |
| 3709 | 3835 | "version": "2.20.3", |
| 3710 | 3836 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", |
@@ -3779,7 +3905,6 @@ | ||
| 3779 | 3905 | "version": "3.2.3", |
| 3780 | 3906 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", |
| 3781 | 3907 | "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", |
| 3782 | − "devOptional": true, | |
| 3783 | 3908 | "license": "MIT" |
| 3784 | 3909 | }, |
| 3785 | 3910 | "node_modules/data-view-buffer": { |
@@ -3840,7 +3965,6 @@ | ||
| 3840 | 3965 | "version": "4.4.3", |
| 3841 | 3966 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", |
| 3842 | 3967 | "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", |
| 3843 | − "dev": true, | |
| 3844 | 3968 | "license": "MIT", |
| 3845 | 3969 | "dependencies": { |
| 3846 | 3970 | "ms": "^2.1.3" |
@@ -3854,6 +3978,19 @@ | ||
| 3854 | 3978 | } |
| 3855 | 3979 | } |
| 3856 | 3980 | }, |
| 3981 | + "node_modules/decode-named-character-reference": { | |
| 3982 | + "version": "1.3.0", | |
| 3983 | + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", | |
| 3984 | + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", | |
| 3985 | + "license": "MIT", | |
| 3986 | + "dependencies": { | |
| 3987 | + "character-entities": "^2.0.0" | |
| 3988 | + }, | |
| 3989 | + "funding": { | |
| 3990 | + "type": "github", | |
| 3991 | + "url": "https://github.com/sponsors/wooorm" | |
| 3992 | + } | |
| 3993 | + }, | |
| 3857 | 3994 | "node_modules/deep-is": { |
| 3858 | 3995 | "version": "0.1.4", |
| 3859 | 3996 | "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", |
@@ -3907,6 +4044,28 @@ | ||
| 3907 | 4044 | "url": "https://github.com/sponsors/ljharb" |
| 3908 | 4045 | } |
| 3909 | 4046 | }, |
| 4047 | + "node_modules/dequal": { | |
| 4048 | + "version": "2.0.3", | |
| 4049 | + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | |
| 4050 | + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", | |
| 4051 | + "license": "MIT", | |
| 4052 | + "engines": { | |
| 4053 | + "node": ">=6" | |
| 4054 | + } | |
| 4055 | + }, | |
| 4056 | + "node_modules/devlop": { | |
| 4057 | + "version": "1.1.0", | |
| 4058 | + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", | |
| 4059 | + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", | |
| 4060 | + "license": "MIT", | |
| 4061 | + "dependencies": { | |
| 4062 | + "dequal": "^2.0.0" | |
| 4063 | + }, | |
| 4064 | + "funding": { | |
| 4065 | + "type": "github", | |
| 4066 | + "url": "https://github.com/sponsors/wooorm" | |
| 4067 | + } | |
| 4068 | + }, | |
| 3910 | 4069 | "node_modules/dunder-proto": { |
| 3911 | 4070 | "version": "1.0.1", |
| 3912 | 4071 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", |
@@ -4392,6 +4551,16 @@ | ||
| 4392 | 4551 | "node": ">=4.0" |
| 4393 | 4552 | } |
| 4394 | 4553 | }, |
| 4554 | + "node_modules/estree-util-is-identifier-name": { | |
| 4555 | + "version": "3.0.0", | |
| 4556 | + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", | |
| 4557 | + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", | |
| 4558 | + "license": "MIT", | |
| 4559 | + "funding": { | |
| 4560 | + "type": "opencollective", | |
| 4561 | + "url": "https://opencollective.com/unified" | |
| 4562 | + } | |
| 4563 | + }, | |
| 4395 | 4564 | "node_modules/estree-walker": { |
| 4396 | 4565 | "version": "2.0.2", |
| 4397 | 4566 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", |
@@ -4422,6 +4591,12 @@ | ||
| 4422 | 4591 | "url": "https://github.com/bgub/eta?sponsor=1" |
| 4423 | 4592 | } |
| 4424 | 4593 | }, |
| 4594 | + "node_modules/extend": { | |
| 4595 | + "version": "3.0.2", | |
| 4596 | + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", | |
| 4597 | + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", | |
| 4598 | + "license": "MIT" | |
| 4599 | + }, | |
| 4425 | 4600 | "node_modules/fast-deep-equal": { |
| 4426 | 4601 | "version": "3.1.3", |
| 4427 | 4602 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", |
@@ -4943,6 +5118,94 @@ | ||
| 4943 | 5118 | "node": ">= 0.4" |
| 4944 | 5119 | } |
| 4945 | 5120 | }, |
| 5121 | + "node_modules/hast-util-is-element": { | |
| 5122 | + "version": "3.0.0", | |
| 5123 | + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", | |
| 5124 | + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", | |
| 5125 | + "license": "MIT", | |
| 5126 | + "dependencies": { | |
| 5127 | + "@types/hast": "^3.0.0" | |
| 5128 | + }, | |
| 5129 | + "funding": { | |
| 5130 | + "type": "opencollective", | |
| 5131 | + "url": "https://opencollective.com/unified" | |
| 5132 | + } | |
| 5133 | + }, | |
| 5134 | + "node_modules/hast-util-to-jsx-runtime": { | |
| 5135 | + "version": "2.3.6", | |
| 5136 | + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", | |
| 5137 | + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", | |
| 5138 | + "license": "MIT", | |
| 5139 | + "dependencies": { | |
| 5140 | + "@types/estree": "^1.0.0", | |
| 5141 | + "@types/hast": "^3.0.0", | |
| 5142 | + "@types/unist": "^3.0.0", | |
| 5143 | + "comma-separated-tokens": "^2.0.0", | |
| 5144 | + "devlop": "^1.0.0", | |
| 5145 | + "estree-util-is-identifier-name": "^3.0.0", | |
| 5146 | + "hast-util-whitespace": "^3.0.0", | |
| 5147 | + "mdast-util-mdx-expression": "^2.0.0", | |
| 5148 | + "mdast-util-mdx-jsx": "^3.0.0", | |
| 5149 | + "mdast-util-mdxjs-esm": "^2.0.0", | |
| 5150 | + "property-information": "^7.0.0", | |
| 5151 | + "space-separated-tokens": "^2.0.0", | |
| 5152 | + "style-to-js": "^1.0.0", | |
| 5153 | + "unist-util-position": "^5.0.0", | |
| 5154 | + "vfile-message": "^4.0.0" | |
| 5155 | + }, | |
| 5156 | + "funding": { | |
| 5157 | + "type": "opencollective", | |
| 5158 | + "url": "https://opencollective.com/unified" | |
| 5159 | + } | |
| 5160 | + }, | |
| 5161 | + "node_modules/hast-util-to-text": { | |
| 5162 | + "version": "4.0.2", | |
| 5163 | + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", | |
| 5164 | + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", | |
| 5165 | + "license": "MIT", | |
| 5166 | + "dependencies": { | |
| 5167 | + "@types/hast": "^3.0.0", | |
| 5168 | + "@types/unist": "^3.0.0", | |
| 5169 | + "hast-util-is-element": "^3.0.0", | |
| 5170 | + "unist-util-find-after": "^5.0.0" | |
| 5171 | + }, | |
| 5172 | + "funding": { | |
| 5173 | + "type": "opencollective", | |
| 5174 | + "url": "https://opencollective.com/unified" | |
| 5175 | + } | |
| 5176 | + }, | |
| 5177 | + "node_modules/hast-util-whitespace": { | |
| 5178 | + "version": "3.0.0", | |
| 5179 | + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", | |
| 5180 | + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", | |
| 5181 | + "license": "MIT", | |
| 5182 | + "dependencies": { | |
| 5183 | + "@types/hast": "^3.0.0" | |
| 5184 | + }, | |
| 5185 | + "funding": { | |
| 5186 | + "type": "opencollective", | |
| 5187 | + "url": "https://opencollective.com/unified" | |
| 5188 | + } | |
| 5189 | + }, | |
| 5190 | + "node_modules/highlight.js": { | |
| 5191 | + "version": "11.11.1", | |
| 5192 | + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", | |
| 5193 | + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", | |
| 5194 | + "license": "BSD-3-Clause", | |
| 5195 | + "engines": { | |
| 5196 | + "node": ">=12.0.0" | |
| 5197 | + } | |
| 5198 | + }, | |
| 5199 | + "node_modules/html-url-attributes": { | |
| 5200 | + "version": "3.0.1", | |
| 5201 | + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", | |
| 5202 | + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", | |
| 5203 | + "license": "MIT", | |
| 5204 | + "funding": { | |
| 5205 | + "type": "opencollective", | |
| 5206 | + "url": "https://opencollective.com/unified" | |
| 5207 | + } | |
| 5208 | + }, | |
| 4946 | 5209 | "node_modules/idb": { |
| 4947 | 5210 | "version": "7.1.1", |
| 4948 | 5211 | "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", |
@@ -4987,6 +5250,12 @@ | ||
| 4987 | 5250 | "node": ">=0.8.19" |
| 4988 | 5251 | } |
| 4989 | 5252 | }, |
| 5253 | + "node_modules/inline-style-parser": { | |
| 5254 | + "version": "0.2.7", | |
| 5255 | + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", | |
| 5256 | + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", | |
| 5257 | + "license": "MIT" | |
| 5258 | + }, | |
| 4990 | 5259 | "node_modules/internal-slot": { |
| 4991 | 5260 | "version": "1.1.0", |
| 4992 | 5261 | "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", |
@@ -5002,6 +5271,30 @@ | ||
| 5002 | 5271 | "node": ">= 0.4" |
| 5003 | 5272 | } |
| 5004 | 5273 | }, |
| 5274 | + "node_modules/is-alphabetical": { | |
| 5275 | + "version": "2.0.1", | |
| 5276 | + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", | |
| 5277 | + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", | |
| 5278 | + "license": "MIT", | |
| 5279 | + "funding": { | |
| 5280 | + "type": "github", | |
| 5281 | + "url": "https://github.com/sponsors/wooorm" | |
| 5282 | + } | |
| 5283 | + }, | |
| 5284 | + "node_modules/is-alphanumerical": { | |
| 5285 | + "version": "2.0.1", | |
| 5286 | + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", | |
| 5287 | + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", | |
| 5288 | + "license": "MIT", | |
| 5289 | + "dependencies": { | |
| 5290 | + "is-alphabetical": "^2.0.0", | |
| 5291 | + "is-decimal": "^2.0.0" | |
| 5292 | + }, | |
| 5293 | + "funding": { | |
| 5294 | + "type": "github", | |
| 5295 | + "url": "https://github.com/sponsors/wooorm" | |
| 5296 | + } | |
| 5297 | + }, | |
| 5005 | 5298 | "node_modules/is-array-buffer": { |
| 5006 | 5299 | "version": "3.0.5", |
| 5007 | 5300 | "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", |
@@ -5137,6 +5430,16 @@ | ||
| 5137 | 5430 | "url": "https://github.com/sponsors/ljharb" |
| 5138 | 5431 | } |
| 5139 | 5432 | }, |
| 5433 | + "node_modules/is-decimal": { | |
| 5434 | + "version": "2.0.1", | |
| 5435 | + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", | |
| 5436 | + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", | |
| 5437 | + "license": "MIT", | |
| 5438 | + "funding": { | |
| 5439 | + "type": "github", | |
| 5440 | + "url": "https://github.com/sponsors/wooorm" | |
| 5441 | + } | |
| 5442 | + }, | |
| 5140 | 5443 | "node_modules/is-document.all": { |
| 5141 | 5444 | "version": "1.0.0", |
| 5142 | 5445 | "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", |
@@ -5212,6 +5515,16 @@ | ||
| 5212 | 5515 | "node": ">=0.10.0" |
| 5213 | 5516 | } |
| 5214 | 5517 | }, |
| 5518 | + "node_modules/is-hexadecimal": { | |
| 5519 | + "version": "2.0.1", | |
| 5520 | + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", | |
| 5521 | + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", | |
| 5522 | + "license": "MIT", | |
| 5523 | + "funding": { | |
| 5524 | + "type": "github", | |
| 5525 | + "url": "https://github.com/sponsors/wooorm" | |
| 5526 | + } | |
| 5527 | + }, | |
| 5215 | 5528 | "node_modules/is-map": { |
| 5216 | 5529 | "version": "2.0.3", |
| 5217 | 5530 | "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", |
@@ -5272,6 +5585,18 @@ | ||
| 5272 | 5585 | "node": ">=0.10.0" |
| 5273 | 5586 | } |
| 5274 | 5587 | }, |
| 5588 | + "node_modules/is-plain-obj": { | |
| 5589 | + "version": "4.1.0", | |
| 5590 | + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", | |
| 5591 | + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", | |
| 5592 | + "license": "MIT", | |
| 5593 | + "engines": { | |
| 5594 | + "node": ">=12" | |
| 5595 | + }, | |
| 5596 | + "funding": { | |
| 5597 | + "url": "https://github.com/sponsors/sindresorhus" | |
| 5598 | + } | |
| 5599 | + }, | |
| 5275 | 5600 | "node_modules/is-regex": { |
| 5276 | 5601 | "version": "1.2.1", |
| 5277 | 5602 | "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", |
@@ -5652,6 +5977,31 @@ | ||
| 5652 | 5977 | "dev": true, |
| 5653 | 5978 | "license": "MIT" |
| 5654 | 5979 | }, |
| 5980 | + "node_modules/longest-streak": { | |
| 5981 | + "version": "3.1.0", | |
| 5982 | + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", | |
| 5983 | + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", | |
| 5984 | + "license": "MIT", | |
| 5985 | + "funding": { | |
| 5986 | + "type": "github", | |
| 5987 | + "url": "https://github.com/sponsors/wooorm" | |
| 5988 | + } | |
| 5989 | + }, | |
| 5990 | + "node_modules/lowlight": { | |
| 5991 | + "version": "3.3.0", | |
| 5992 | + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", | |
| 5993 | + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", | |
| 5994 | + "license": "MIT", | |
| 5995 | + "dependencies": { | |
| 5996 | + "@types/hast": "^3.0.0", | |
| 5997 | + "devlop": "^1.0.0", | |
| 5998 | + "highlight.js": "~11.11.0" | |
| 5999 | + }, | |
| 6000 | + "funding": { | |
| 6001 | + "type": "github", | |
| 6002 | + "url": "https://github.com/sponsors/wooorm" | |
| 6003 | + } | |
| 6004 | + }, | |
| 5655 | 6005 | "node_modules/lru-cache": { |
| 5656 | 6006 | "version": "5.1.1", |
| 5657 | 6007 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", |
@@ -5672,6 +6022,16 @@ | ||
| 5672 | 6022 | "@jridgewell/sourcemap-codec": "^1.5.5" |
| 5673 | 6023 | } |
| 5674 | 6024 | }, |
| 6025 | + "node_modules/markdown-table": { | |
| 6026 | + "version": "3.0.4", | |
| 6027 | + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", | |
| 6028 | + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", | |
| 6029 | + "license": "MIT", | |
| 6030 | + "funding": { | |
| 6031 | + "type": "github", | |
| 6032 | + "url": "https://github.com/sponsors/wooorm" | |
| 6033 | + } | |
| 6034 | + }, | |
| 5675 | 6035 | "node_modules/math-intrinsics": { |
| 5676 | 6036 | "version": "1.1.0", |
| 5677 | 6037 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", |
@@ -5682,63 +6042,907 @@ | ||
| 5682 | 6042 | "node": ">= 0.4" |
| 5683 | 6043 | } |
| 5684 | 6044 | }, |
| 5685 | − "node_modules/minimatch": { | |
| 5686 | − "version": "10.2.6", | |
| 5687 | − "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", | |
| 5688 | − "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", | |
| 5689 | − "dev": true, | |
| 5690 | − "license": "BlueOak-1.0.0", | |
| 6045 | + "node_modules/mdast-util-find-and-replace": { | |
| 6046 | + "version": "3.0.2", | |
| 6047 | + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", | |
| 6048 | + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", | |
| 6049 | + "license": "MIT", | |
| 5691 | 6050 | "dependencies": { |
| 5692 | − "brace-expansion": "^5.0.8" | |
| 5693 | − }, | |
| 5694 | − "engines": { | |
| 5695 | − "node": "18 || 20 || >=22" | |
| 6051 | + "@types/mdast": "^4.0.0", | |
| 6052 | + "escape-string-regexp": "^5.0.0", | |
| 6053 | + "unist-util-is": "^6.0.0", | |
| 6054 | + "unist-util-visit-parents": "^6.0.0" | |
| 5696 | 6055 | }, |
| 5697 | 6056 | "funding": { |
| 5698 | − "url": "https://github.com/sponsors/isaacs" | |
| 6057 | + "type": "opencollective", | |
| 6058 | + "url": "https://opencollective.com/unified" | |
| 5699 | 6059 | } |
| 5700 | 6060 | }, |
| 5701 | − "node_modules/minipass": { | |
| 5702 | − "version": "7.1.3", | |
| 5703 | − "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", | |
| 5704 | − "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", | |
| 5705 | − "dev": true, | |
| 5706 | − "license": "BlueOak-1.0.0", | |
| 6061 | + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { | |
| 6062 | + "version": "5.0.0", | |
| 6063 | + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", | |
| 6064 | + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", | |
| 6065 | + "license": "MIT", | |
| 5707 | 6066 | "engines": { |
| 5708 | − "node": ">=16 || 14 >=14.17" | |
| 6067 | + "node": ">=12" | |
| 6068 | + }, | |
| 6069 | + "funding": { | |
| 6070 | + "url": "https://github.com/sponsors/sindresorhus" | |
| 5709 | 6071 | } |
| 5710 | 6072 | }, |
| 5711 | − "node_modules/ms": { | |
| 5712 | − "version": "2.1.3", | |
| 5713 | − "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |
| 5714 | − "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", | |
| 5715 | − "dev": true, | |
| 5716 | − "license": "MIT" | |
| 5717 | − }, | |
| 5718 | − "node_modules/nanoid": { | |
| 5719 | − "version": "3.3.16", | |
| 5720 | − "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", | |
| 5721 | − "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", | |
| 5722 | − "dev": true, | |
| 5723 | − "funding": [ | |
| 5724 | − { | |
| 5725 | − "type": "github", | |
| 5726 | − "url": "https://github.com/sponsors/ai" | |
| 5727 | − } | |
| 5728 | − ], | |
| 6073 | + "node_modules/mdast-util-from-markdown": { | |
| 6074 | + "version": "2.0.3", | |
| 6075 | + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", | |
| 6076 | + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", | |
| 5729 | 6077 | "license": "MIT", |
| 5730 | − "bin": { | |
| 5731 | − "nanoid": "bin/nanoid.cjs" | |
| 6078 | + "dependencies": { | |
| 6079 | + "@types/mdast": "^4.0.0", | |
| 6080 | + "@types/unist": "^3.0.0", | |
| 6081 | + "decode-named-character-reference": "^1.0.0", | |
| 6082 | + "devlop": "^1.0.0", | |
| 6083 | + "mdast-util-to-string": "^4.0.0", | |
| 6084 | + "micromark": "^4.0.0", | |
| 6085 | + "micromark-util-decode-numeric-character-reference": "^2.0.0", | |
| 6086 | + "micromark-util-decode-string": "^2.0.0", | |
| 6087 | + "micromark-util-normalize-identifier": "^2.0.0", | |
| 6088 | + "micromark-util-symbol": "^2.0.0", | |
| 6089 | + "micromark-util-types": "^2.0.0", | |
| 6090 | + "unist-util-stringify-position": "^4.0.0" | |
| 5732 | 6091 | }, |
| 5733 | − "engines": { | |
| 5734 | − "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 6092 | + "funding": { | |
| 6093 | + "type": "opencollective", | |
| 6094 | + "url": "https://opencollective.com/unified" | |
| 5735 | 6095 | } |
| 5736 | 6096 | }, |
| 5737 | − "node_modules/natural-compare": { | |
| 5738 | − "version": "1.4.0", | |
| 5739 | − "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", | |
| 5740 | − "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", | |
| 5741 | − "dev": true, | |
| 6097 | + "node_modules/mdast-util-gfm": { | |
| 6098 | + "version": "3.1.0", | |
| 6099 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", | |
| 6100 | + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", | |
| 6101 | + "license": "MIT", | |
| 6102 | + "dependencies": { | |
| 6103 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6104 | + "mdast-util-gfm-autolink-literal": "^2.0.0", | |
| 6105 | + "mdast-util-gfm-footnote": "^2.0.0", | |
| 6106 | + "mdast-util-gfm-strikethrough": "^2.0.0", | |
| 6107 | + "mdast-util-gfm-table": "^2.0.0", | |
| 6108 | + "mdast-util-gfm-task-list-item": "^2.0.0", | |
| 6109 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6110 | + }, | |
| 6111 | + "funding": { | |
| 6112 | + "type": "opencollective", | |
| 6113 | + "url": "https://opencollective.com/unified" | |
| 6114 | + } | |
| 6115 | + }, | |
| 6116 | + "node_modules/mdast-util-gfm-autolink-literal": { | |
| 6117 | + "version": "2.0.1", | |
| 6118 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", | |
| 6119 | + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", | |
| 6120 | + "license": "MIT", | |
| 6121 | + "dependencies": { | |
| 6122 | + "@types/mdast": "^4.0.0", | |
| 6123 | + "ccount": "^2.0.0", | |
| 6124 | + "devlop": "^1.0.0", | |
| 6125 | + "mdast-util-find-and-replace": "^3.0.0", | |
| 6126 | + "micromark-util-character": "^2.0.0" | |
| 6127 | + }, | |
| 6128 | + "funding": { | |
| 6129 | + "type": "opencollective", | |
| 6130 | + "url": "https://opencollective.com/unified" | |
| 6131 | + } | |
| 6132 | + }, | |
| 6133 | + "node_modules/mdast-util-gfm-footnote": { | |
| 6134 | + "version": "2.1.0", | |
| 6135 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", | |
| 6136 | + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", | |
| 6137 | + "license": "MIT", | |
| 6138 | + "dependencies": { | |
| 6139 | + "@types/mdast": "^4.0.0", | |
| 6140 | + "devlop": "^1.1.0", | |
| 6141 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6142 | + "mdast-util-to-markdown": "^2.0.0", | |
| 6143 | + "micromark-util-normalize-identifier": "^2.0.0" | |
| 6144 | + }, | |
| 6145 | + "funding": { | |
| 6146 | + "type": "opencollective", | |
| 6147 | + "url": "https://opencollective.com/unified" | |
| 6148 | + } | |
| 6149 | + }, | |
| 6150 | + "node_modules/mdast-util-gfm-strikethrough": { | |
| 6151 | + "version": "2.0.0", | |
| 6152 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", | |
| 6153 | + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", | |
| 6154 | + "license": "MIT", | |
| 6155 | + "dependencies": { | |
| 6156 | + "@types/mdast": "^4.0.0", | |
| 6157 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6158 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6159 | + }, | |
| 6160 | + "funding": { | |
| 6161 | + "type": "opencollective", | |
| 6162 | + "url": "https://opencollective.com/unified" | |
| 6163 | + } | |
| 6164 | + }, | |
| 6165 | + "node_modules/mdast-util-gfm-table": { | |
| 6166 | + "version": "2.0.0", | |
| 6167 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", | |
| 6168 | + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", | |
| 6169 | + "license": "MIT", | |
| 6170 | + "dependencies": { | |
| 6171 | + "@types/mdast": "^4.0.0", | |
| 6172 | + "devlop": "^1.0.0", | |
| 6173 | + "markdown-table": "^3.0.0", | |
| 6174 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6175 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6176 | + }, | |
| 6177 | + "funding": { | |
| 6178 | + "type": "opencollective", | |
| 6179 | + "url": "https://opencollective.com/unified" | |
| 6180 | + } | |
| 6181 | + }, | |
| 6182 | + "node_modules/mdast-util-gfm-task-list-item": { | |
| 6183 | + "version": "2.0.0", | |
| 6184 | + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", | |
| 6185 | + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", | |
| 6186 | + "license": "MIT", | |
| 6187 | + "dependencies": { | |
| 6188 | + "@types/mdast": "^4.0.0", | |
| 6189 | + "devlop": "^1.0.0", | |
| 6190 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6191 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6192 | + }, | |
| 6193 | + "funding": { | |
| 6194 | + "type": "opencollective", | |
| 6195 | + "url": "https://opencollective.com/unified" | |
| 6196 | + } | |
| 6197 | + }, | |
| 6198 | + "node_modules/mdast-util-mdx-expression": { | |
| 6199 | + "version": "2.0.1", | |
| 6200 | + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", | |
| 6201 | + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", | |
| 6202 | + "license": "MIT", | |
| 6203 | + "dependencies": { | |
| 6204 | + "@types/estree-jsx": "^1.0.0", | |
| 6205 | + "@types/hast": "^3.0.0", | |
| 6206 | + "@types/mdast": "^4.0.0", | |
| 6207 | + "devlop": "^1.0.0", | |
| 6208 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6209 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6210 | + }, | |
| 6211 | + "funding": { | |
| 6212 | + "type": "opencollective", | |
| 6213 | + "url": "https://opencollective.com/unified" | |
| 6214 | + } | |
| 6215 | + }, | |
| 6216 | + "node_modules/mdast-util-mdx-jsx": { | |
| 6217 | + "version": "3.2.0", | |
| 6218 | + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", | |
| 6219 | + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", | |
| 6220 | + "license": "MIT", | |
| 6221 | + "dependencies": { | |
| 6222 | + "@types/estree-jsx": "^1.0.0", | |
| 6223 | + "@types/hast": "^3.0.0", | |
| 6224 | + "@types/mdast": "^4.0.0", | |
| 6225 | + "@types/unist": "^3.0.0", | |
| 6226 | + "ccount": "^2.0.0", | |
| 6227 | + "devlop": "^1.1.0", | |
| 6228 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6229 | + "mdast-util-to-markdown": "^2.0.0", | |
| 6230 | + "parse-entities": "^4.0.0", | |
| 6231 | + "stringify-entities": "^4.0.0", | |
| 6232 | + "unist-util-stringify-position": "^4.0.0", | |
| 6233 | + "vfile-message": "^4.0.0" | |
| 6234 | + }, | |
| 6235 | + "funding": { | |
| 6236 | + "type": "opencollective", | |
| 6237 | + "url": "https://opencollective.com/unified" | |
| 6238 | + } | |
| 6239 | + }, | |
| 6240 | + "node_modules/mdast-util-mdxjs-esm": { | |
| 6241 | + "version": "2.0.1", | |
| 6242 | + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", | |
| 6243 | + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", | |
| 6244 | + "license": "MIT", | |
| 6245 | + "dependencies": { | |
| 6246 | + "@types/estree-jsx": "^1.0.0", | |
| 6247 | + "@types/hast": "^3.0.0", | |
| 6248 | + "@types/mdast": "^4.0.0", | |
| 6249 | + "devlop": "^1.0.0", | |
| 6250 | + "mdast-util-from-markdown": "^2.0.0", | |
| 6251 | + "mdast-util-to-markdown": "^2.0.0" | |
| 6252 | + }, | |
| 6253 | + "funding": { | |
| 6254 | + "type": "opencollective", | |
| 6255 | + "url": "https://opencollective.com/unified" | |
| 6256 | + } | |
| 6257 | + }, | |
| 6258 | + "node_modules/mdast-util-phrasing": { | |
| 6259 | + "version": "4.1.0", | |
| 6260 | + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", | |
| 6261 | + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", | |
| 6262 | + "license": "MIT", | |
| 6263 | + "dependencies": { | |
| 6264 | + "@types/mdast": "^4.0.0", | |
| 6265 | + "unist-util-is": "^6.0.0" | |
| 6266 | + }, | |
| 6267 | + "funding": { | |
| 6268 | + "type": "opencollective", | |
| 6269 | + "url": "https://opencollective.com/unified" | |
| 6270 | + } | |
| 6271 | + }, | |
| 6272 | + "node_modules/mdast-util-to-hast": { | |
| 6273 | + "version": "13.2.1", | |
| 6274 | + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", | |
| 6275 | + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", | |
| 6276 | + "license": "MIT", | |
| 6277 | + "dependencies": { | |
| 6278 | + "@types/hast": "^3.0.0", | |
| 6279 | + "@types/mdast": "^4.0.0", | |
| 6280 | + "@ungap/structured-clone": "^1.0.0", | |
| 6281 | + "devlop": "^1.0.0", | |
| 6282 | + "micromark-util-sanitize-uri": "^2.0.0", | |
| 6283 | + "trim-lines": "^3.0.0", | |
| 6284 | + "unist-util-position": "^5.0.0", | |
| 6285 | + "unist-util-visit": "^5.0.0", | |
| 6286 | + "vfile": "^6.0.0" | |
| 6287 | + }, | |
| 6288 | + "funding": { | |
| 6289 | + "type": "opencollective", | |
| 6290 | + "url": "https://opencollective.com/unified" | |
| 6291 | + } | |
| 6292 | + }, | |
| 6293 | + "node_modules/mdast-util-to-markdown": { | |
| 6294 | + "version": "2.1.2", | |
| 6295 | + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", | |
| 6296 | + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", | |
| 6297 | + "license": "MIT", | |
| 6298 | + "dependencies": { | |
| 6299 | + "@types/mdast": "^4.0.0", | |
| 6300 | + "@types/unist": "^3.0.0", | |
| 6301 | + "longest-streak": "^3.0.0", | |
| 6302 | + "mdast-util-phrasing": "^4.0.0", | |
| 6303 | + "mdast-util-to-string": "^4.0.0", | |
| 6304 | + "micromark-util-classify-character": "^2.0.0", | |
| 6305 | + "micromark-util-decode-string": "^2.0.0", | |
| 6306 | + "unist-util-visit": "^5.0.0", | |
| 6307 | + "zwitch": "^2.0.0" | |
| 6308 | + }, | |
| 6309 | + "funding": { | |
| 6310 | + "type": "opencollective", | |
| 6311 | + "url": "https://opencollective.com/unified" | |
| 6312 | + } | |
| 6313 | + }, | |
| 6314 | + "node_modules/mdast-util-to-string": { | |
| 6315 | + "version": "4.0.0", | |
| 6316 | + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", | |
| 6317 | + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", | |
| 6318 | + "license": "MIT", | |
| 6319 | + "dependencies": { | |
| 6320 | + "@types/mdast": "^4.0.0" | |
| 6321 | + }, | |
| 6322 | + "funding": { | |
| 6323 | + "type": "opencollective", | |
| 6324 | + "url": "https://opencollective.com/unified" | |
| 6325 | + } | |
| 6326 | + }, | |
| 6327 | + "node_modules/micromark": { | |
| 6328 | + "version": "4.0.2", | |
| 6329 | + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", | |
| 6330 | + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", | |
| 6331 | + "funding": [ | |
| 6332 | + { | |
| 6333 | + "type": "GitHub Sponsors", | |
| 6334 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6335 | + }, | |
| 6336 | + { | |
| 6337 | + "type": "OpenCollective", | |
| 6338 | + "url": "https://opencollective.com/unified" | |
| 6339 | + } | |
| 6340 | + ], | |
| 6341 | + "license": "MIT", | |
| 6342 | + "dependencies": { | |
| 6343 | + "@types/debug": "^4.0.0", | |
| 6344 | + "debug": "^4.0.0", | |
| 6345 | + "decode-named-character-reference": "^1.0.0", | |
| 6346 | + "devlop": "^1.0.0", | |
| 6347 | + "micromark-core-commonmark": "^2.0.0", | |
| 6348 | + "micromark-factory-space": "^2.0.0", | |
| 6349 | + "micromark-util-character": "^2.0.0", | |
| 6350 | + "micromark-util-chunked": "^2.0.0", | |
| 6351 | + "micromark-util-combine-extensions": "^2.0.0", | |
| 6352 | + "micromark-util-decode-numeric-character-reference": "^2.0.0", | |
| 6353 | + "micromark-util-encode": "^2.0.0", | |
| 6354 | + "micromark-util-normalize-identifier": "^2.0.0", | |
| 6355 | + "micromark-util-resolve-all": "^2.0.0", | |
| 6356 | + "micromark-util-sanitize-uri": "^2.0.0", | |
| 6357 | + "micromark-util-subtokenize": "^2.0.0", | |
| 6358 | + "micromark-util-symbol": "^2.0.0", | |
| 6359 | + "micromark-util-types": "^2.0.0" | |
| 6360 | + } | |
| 6361 | + }, | |
| 6362 | + "node_modules/micromark-core-commonmark": { | |
| 6363 | + "version": "2.0.3", | |
| 6364 | + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", | |
| 6365 | + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", | |
| 6366 | + "funding": [ | |
| 6367 | + { | |
| 6368 | + "type": "GitHub Sponsors", | |
| 6369 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6370 | + }, | |
| 6371 | + { | |
| 6372 | + "type": "OpenCollective", | |
| 6373 | + "url": "https://opencollective.com/unified" | |
| 6374 | + } | |
| 6375 | + ], | |
| 6376 | + "license": "MIT", | |
| 6377 | + "dependencies": { | |
| 6378 | + "decode-named-character-reference": "^1.0.0", | |
| 6379 | + "devlop": "^1.0.0", | |
| 6380 | + "micromark-factory-destination": "^2.0.0", | |
| 6381 | + "micromark-factory-label": "^2.0.0", | |
| 6382 | + "micromark-factory-space": "^2.0.0", | |
| 6383 | + "micromark-factory-title": "^2.0.0", | |
| 6384 | + "micromark-factory-whitespace": "^2.0.0", | |
| 6385 | + "micromark-util-character": "^2.0.0", | |
| 6386 | + "micromark-util-chunked": "^2.0.0", | |
| 6387 | + "micromark-util-classify-character": "^2.0.0", | |
| 6388 | + "micromark-util-html-tag-name": "^2.0.0", | |
| 6389 | + "micromark-util-normalize-identifier": "^2.0.0", | |
| 6390 | + "micromark-util-resolve-all": "^2.0.0", | |
| 6391 | + "micromark-util-subtokenize": "^2.0.0", | |
| 6392 | + "micromark-util-symbol": "^2.0.0", | |
| 6393 | + "micromark-util-types": "^2.0.0" | |
| 6394 | + } | |
| 6395 | + }, | |
| 6396 | + "node_modules/micromark-extension-gfm": { | |
| 6397 | + "version": "3.0.0", | |
| 6398 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", | |
| 6399 | + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", | |
| 6400 | + "license": "MIT", | |
| 6401 | + "dependencies": { | |
| 6402 | + "micromark-extension-gfm-autolink-literal": "^2.0.0", | |
| 6403 | + "micromark-extension-gfm-footnote": "^2.0.0", | |
| 6404 | + "micromark-extension-gfm-strikethrough": "^2.0.0", | |
| 6405 | + "micromark-extension-gfm-table": "^2.0.0", | |
| 6406 | + "micromark-extension-gfm-tagfilter": "^2.0.0", | |
| 6407 | + "micromark-extension-gfm-task-list-item": "^2.0.0", | |
| 6408 | + "micromark-util-combine-extensions": "^2.0.0", | |
| 6409 | + "micromark-util-types": "^2.0.0" | |
| 6410 | + }, | |
| 6411 | + "funding": { | |
| 6412 | + "type": "opencollective", | |
| 6413 | + "url": "https://opencollective.com/unified" | |
| 6414 | + } | |
| 6415 | + }, | |
| 6416 | + "node_modules/micromark-extension-gfm-autolink-literal": { | |
| 6417 | + "version": "2.1.0", | |
| 6418 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", | |
| 6419 | + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", | |
| 6420 | + "license": "MIT", | |
| 6421 | + "dependencies": { | |
| 6422 | + "micromark-util-character": "^2.0.0", | |
| 6423 | + "micromark-util-sanitize-uri": "^2.0.0", | |
| 6424 | + "micromark-util-symbol": "^2.0.0", | |
| 6425 | + "micromark-util-types": "^2.0.0" | |
| 6426 | + }, | |
| 6427 | + "funding": { | |
| 6428 | + "type": "opencollective", | |
| 6429 | + "url": "https://opencollective.com/unified" | |
| 6430 | + } | |
| 6431 | + }, | |
| 6432 | + "node_modules/micromark-extension-gfm-footnote": { | |
| 6433 | + "version": "2.1.0", | |
| 6434 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", | |
| 6435 | + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", | |
| 6436 | + "license": "MIT", | |
| 6437 | + "dependencies": { | |
| 6438 | + "devlop": "^1.0.0", | |
| 6439 | + "micromark-core-commonmark": "^2.0.0", | |
| 6440 | + "micromark-factory-space": "^2.0.0", | |
| 6441 | + "micromark-util-character": "^2.0.0", | |
| 6442 | + "micromark-util-normalize-identifier": "^2.0.0", | |
| 6443 | + "micromark-util-sanitize-uri": "^2.0.0", | |
| 6444 | + "micromark-util-symbol": "^2.0.0", | |
| 6445 | + "micromark-util-types": "^2.0.0" | |
| 6446 | + }, | |
| 6447 | + "funding": { | |
| 6448 | + "type": "opencollective", | |
| 6449 | + "url": "https://opencollective.com/unified" | |
| 6450 | + } | |
| 6451 | + }, | |
| 6452 | + "node_modules/micromark-extension-gfm-strikethrough": { | |
| 6453 | + "version": "2.1.0", | |
| 6454 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", | |
| 6455 | + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", | |
| 6456 | + "license": "MIT", | |
| 6457 | + "dependencies": { | |
| 6458 | + "devlop": "^1.0.0", | |
| 6459 | + "micromark-util-chunked": "^2.0.0", | |
| 6460 | + "micromark-util-classify-character": "^2.0.0", | |
| 6461 | + "micromark-util-resolve-all": "^2.0.0", | |
| 6462 | + "micromark-util-symbol": "^2.0.0", | |
| 6463 | + "micromark-util-types": "^2.0.0" | |
| 6464 | + }, | |
| 6465 | + "funding": { | |
| 6466 | + "type": "opencollective", | |
| 6467 | + "url": "https://opencollective.com/unified" | |
| 6468 | + } | |
| 6469 | + }, | |
| 6470 | + "node_modules/micromark-extension-gfm-table": { | |
| 6471 | + "version": "2.1.1", | |
| 6472 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", | |
| 6473 | + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", | |
| 6474 | + "license": "MIT", | |
| 6475 | + "dependencies": { | |
| 6476 | + "devlop": "^1.0.0", | |
| 6477 | + "micromark-factory-space": "^2.0.0", | |
| 6478 | + "micromark-util-character": "^2.0.0", | |
| 6479 | + "micromark-util-symbol": "^2.0.0", | |
| 6480 | + "micromark-util-types": "^2.0.0" | |
| 6481 | + }, | |
| 6482 | + "funding": { | |
| 6483 | + "type": "opencollective", | |
| 6484 | + "url": "https://opencollective.com/unified" | |
| 6485 | + } | |
| 6486 | + }, | |
| 6487 | + "node_modules/micromark-extension-gfm-tagfilter": { | |
| 6488 | + "version": "2.0.0", | |
| 6489 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", | |
| 6490 | + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", | |
| 6491 | + "license": "MIT", | |
| 6492 | + "dependencies": { | |
| 6493 | + "micromark-util-types": "^2.0.0" | |
| 6494 | + }, | |
| 6495 | + "funding": { | |
| 6496 | + "type": "opencollective", | |
| 6497 | + "url": "https://opencollective.com/unified" | |
| 6498 | + } | |
| 6499 | + }, | |
| 6500 | + "node_modules/micromark-extension-gfm-task-list-item": { | |
| 6501 | + "version": "2.1.0", | |
| 6502 | + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", | |
| 6503 | + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", | |
| 6504 | + "license": "MIT", | |
| 6505 | + "dependencies": { | |
| 6506 | + "devlop": "^1.0.0", | |
| 6507 | + "micromark-factory-space": "^2.0.0", | |
| 6508 | + "micromark-util-character": "^2.0.0", | |
| 6509 | + "micromark-util-symbol": "^2.0.0", | |
| 6510 | + "micromark-util-types": "^2.0.0" | |
| 6511 | + }, | |
| 6512 | + "funding": { | |
| 6513 | + "type": "opencollective", | |
| 6514 | + "url": "https://opencollective.com/unified" | |
| 6515 | + } | |
| 6516 | + }, | |
| 6517 | + "node_modules/micromark-factory-destination": { | |
| 6518 | + "version": "2.0.1", | |
| 6519 | + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", | |
| 6520 | + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", | |
| 6521 | + "funding": [ | |
| 6522 | + { | |
| 6523 | + "type": "GitHub Sponsors", | |
| 6524 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6525 | + }, | |
| 6526 | + { | |
| 6527 | + "type": "OpenCollective", | |
| 6528 | + "url": "https://opencollective.com/unified" | |
| 6529 | + } | |
| 6530 | + ], | |
| 6531 | + "license": "MIT", | |
| 6532 | + "dependencies": { | |
| 6533 | + "micromark-util-character": "^2.0.0", | |
| 6534 | + "micromark-util-symbol": "^2.0.0", | |
| 6535 | + "micromark-util-types": "^2.0.0" | |
| 6536 | + } | |
| 6537 | + }, | |
| 6538 | + "node_modules/micromark-factory-label": { | |
| 6539 | + "version": "2.0.1", | |
| 6540 | + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", | |
| 6541 | + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", | |
| 6542 | + "funding": [ | |
| 6543 | + { | |
| 6544 | + "type": "GitHub Sponsors", | |
| 6545 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6546 | + }, | |
| 6547 | + { | |
| 6548 | + "type": "OpenCollective", | |
| 6549 | + "url": "https://opencollective.com/unified" | |
| 6550 | + } | |
| 6551 | + ], | |
| 6552 | + "license": "MIT", | |
| 6553 | + "dependencies": { | |
| 6554 | + "devlop": "^1.0.0", | |
| 6555 | + "micromark-util-character": "^2.0.0", | |
| 6556 | + "micromark-util-symbol": "^2.0.0", | |
| 6557 | + "micromark-util-types": "^2.0.0" | |
| 6558 | + } | |
| 6559 | + }, | |
| 6560 | + "node_modules/micromark-factory-space": { | |
| 6561 | + "version": "2.0.1", | |
| 6562 | + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", | |
| 6563 | + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", | |
| 6564 | + "funding": [ | |
| 6565 | + { | |
| 6566 | + "type": "GitHub Sponsors", | |
| 6567 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6568 | + }, | |
| 6569 | + { | |
| 6570 | + "type": "OpenCollective", | |
| 6571 | + "url": "https://opencollective.com/unified" | |
| 6572 | + } | |
| 6573 | + ], | |
| 6574 | + "license": "MIT", | |
| 6575 | + "dependencies": { | |
| 6576 | + "micromark-util-character": "^2.0.0", | |
| 6577 | + "micromark-util-types": "^2.0.0" | |
| 6578 | + } | |
| 6579 | + }, | |
| 6580 | + "node_modules/micromark-factory-title": { | |
| 6581 | + "version": "2.0.1", | |
| 6582 | + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", | |
| 6583 | + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", | |
| 6584 | + "funding": [ | |
| 6585 | + { | |
| 6586 | + "type": "GitHub Sponsors", | |
| 6587 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6588 | + }, | |
| 6589 | + { | |
| 6590 | + "type": "OpenCollective", | |
| 6591 | + "url": "https://opencollective.com/unified" | |
| 6592 | + } | |
| 6593 | + ], | |
| 6594 | + "license": "MIT", | |
| 6595 | + "dependencies": { | |
| 6596 | + "micromark-factory-space": "^2.0.0", | |
| 6597 | + "micromark-util-character": "^2.0.0", | |
| 6598 | + "micromark-util-symbol": "^2.0.0", | |
| 6599 | + "micromark-util-types": "^2.0.0" | |
| 6600 | + } | |
| 6601 | + }, | |
| 6602 | + "node_modules/micromark-factory-whitespace": { | |
| 6603 | + "version": "2.0.1", | |
| 6604 | + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", | |
| 6605 | + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", | |
| 6606 | + "funding": [ | |
| 6607 | + { | |
| 6608 | + "type": "GitHub Sponsors", | |
| 6609 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6610 | + }, | |
| 6611 | + { | |
| 6612 | + "type": "OpenCollective", | |
| 6613 | + "url": "https://opencollective.com/unified" | |
| 6614 | + } | |
| 6615 | + ], | |
| 6616 | + "license": "MIT", | |
| 6617 | + "dependencies": { | |
| 6618 | + "micromark-factory-space": "^2.0.0", | |
| 6619 | + "micromark-util-character": "^2.0.0", | |
| 6620 | + "micromark-util-symbol": "^2.0.0", | |
| 6621 | + "micromark-util-types": "^2.0.0" | |
| 6622 | + } | |
| 6623 | + }, | |
| 6624 | + "node_modules/micromark-util-character": { | |
| 6625 | + "version": "2.1.1", | |
| 6626 | + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", | |
| 6627 | + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", | |
| 6628 | + "funding": [ | |
| 6629 | + { | |
| 6630 | + "type": "GitHub Sponsors", | |
| 6631 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6632 | + }, | |
| 6633 | + { | |
| 6634 | + "type": "OpenCollective", | |
| 6635 | + "url": "https://opencollective.com/unified" | |
| 6636 | + } | |
| 6637 | + ], | |
| 6638 | + "license": "MIT", | |
| 6639 | + "dependencies": { | |
| 6640 | + "micromark-util-symbol": "^2.0.0", | |
| 6641 | + "micromark-util-types": "^2.0.0" | |
| 6642 | + } | |
| 6643 | + }, | |
| 6644 | + "node_modules/micromark-util-chunked": { | |
| 6645 | + "version": "2.0.1", | |
| 6646 | + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", | |
| 6647 | + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", | |
| 6648 | + "funding": [ | |
| 6649 | + { | |
| 6650 | + "type": "GitHub Sponsors", | |
| 6651 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6652 | + }, | |
| 6653 | + { | |
| 6654 | + "type": "OpenCollective", | |
| 6655 | + "url": "https://opencollective.com/unified" | |
| 6656 | + } | |
| 6657 | + ], | |
| 6658 | + "license": "MIT", | |
| 6659 | + "dependencies": { | |
| 6660 | + "micromark-util-symbol": "^2.0.0" | |
| 6661 | + } | |
| 6662 | + }, | |
| 6663 | + "node_modules/micromark-util-classify-character": { | |
| 6664 | + "version": "2.0.1", | |
| 6665 | + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", | |
| 6666 | + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", | |
| 6667 | + "funding": [ | |
| 6668 | + { | |
| 6669 | + "type": "GitHub Sponsors", | |
| 6670 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6671 | + }, | |
| 6672 | + { | |
| 6673 | + "type": "OpenCollective", | |
| 6674 | + "url": "https://opencollective.com/unified" | |
| 6675 | + } | |
| 6676 | + ], | |
| 6677 | + "license": "MIT", | |
| 6678 | + "dependencies": { | |
| 6679 | + "micromark-util-character": "^2.0.0", | |
| 6680 | + "micromark-util-symbol": "^2.0.0", | |
| 6681 | + "micromark-util-types": "^2.0.0" | |
| 6682 | + } | |
| 6683 | + }, | |
| 6684 | + "node_modules/micromark-util-combine-extensions": { | |
| 6685 | + "version": "2.0.1", | |
| 6686 | + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", | |
| 6687 | + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", | |
| 6688 | + "funding": [ | |
| 6689 | + { | |
| 6690 | + "type": "GitHub Sponsors", | |
| 6691 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6692 | + }, | |
| 6693 | + { | |
| 6694 | + "type": "OpenCollective", | |
| 6695 | + "url": "https://opencollective.com/unified" | |
| 6696 | + } | |
| 6697 | + ], | |
| 6698 | + "license": "MIT", | |
| 6699 | + "dependencies": { | |
| 6700 | + "micromark-util-chunked": "^2.0.0", | |
| 6701 | + "micromark-util-types": "^2.0.0" | |
| 6702 | + } | |
| 6703 | + }, | |
| 6704 | + "node_modules/micromark-util-decode-numeric-character-reference": { | |
| 6705 | + "version": "2.0.2", | |
| 6706 | + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", | |
| 6707 | + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", | |
| 6708 | + "funding": [ | |
| 6709 | + { | |
| 6710 | + "type": "GitHub Sponsors", | |
| 6711 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6712 | + }, | |
| 6713 | + { | |
| 6714 | + "type": "OpenCollective", | |
| 6715 | + "url": "https://opencollective.com/unified" | |
| 6716 | + } | |
| 6717 | + ], | |
| 6718 | + "license": "MIT", | |
| 6719 | + "dependencies": { | |
| 6720 | + "micromark-util-symbol": "^2.0.0" | |
| 6721 | + } | |
| 6722 | + }, | |
| 6723 | + "node_modules/micromark-util-decode-string": { | |
| 6724 | + "version": "2.0.1", | |
| 6725 | + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", | |
| 6726 | + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", | |
| 6727 | + "funding": [ | |
| 6728 | + { | |
| 6729 | + "type": "GitHub Sponsors", | |
| 6730 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6731 | + }, | |
| 6732 | + { | |
| 6733 | + "type": "OpenCollective", | |
| 6734 | + "url": "https://opencollective.com/unified" | |
| 6735 | + } | |
| 6736 | + ], | |
| 6737 | + "license": "MIT", | |
| 6738 | + "dependencies": { | |
| 6739 | + "decode-named-character-reference": "^1.0.0", | |
| 6740 | + "micromark-util-character": "^2.0.0", | |
| 6741 | + "micromark-util-decode-numeric-character-reference": "^2.0.0", | |
| 6742 | + "micromark-util-symbol": "^2.0.0" | |
| 6743 | + } | |
| 6744 | + }, | |
| 6745 | + "node_modules/micromark-util-encode": { | |
| 6746 | + "version": "2.0.1", | |
| 6747 | + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", | |
| 6748 | + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", | |
| 6749 | + "funding": [ | |
| 6750 | + { | |
| 6751 | + "type": "GitHub Sponsors", | |
| 6752 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6753 | + }, | |
| 6754 | + { | |
| 6755 | + "type": "OpenCollective", | |
| 6756 | + "url": "https://opencollective.com/unified" | |
| 6757 | + } | |
| 6758 | + ], | |
| 6759 | + "license": "MIT" | |
| 6760 | + }, | |
| 6761 | + "node_modules/micromark-util-html-tag-name": { | |
| 6762 | + "version": "2.0.1", | |
| 6763 | + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", | |
| 6764 | + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", | |
| 6765 | + "funding": [ | |
| 6766 | + { | |
| 6767 | + "type": "GitHub Sponsors", | |
| 6768 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6769 | + }, | |
| 6770 | + { | |
| 6771 | + "type": "OpenCollective", | |
| 6772 | + "url": "https://opencollective.com/unified" | |
| 6773 | + } | |
| 6774 | + ], | |
| 6775 | + "license": "MIT" | |
| 6776 | + }, | |
| 6777 | + "node_modules/micromark-util-normalize-identifier": { | |
| 6778 | + "version": "2.0.1", | |
| 6779 | + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", | |
| 6780 | + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", | |
| 6781 | + "funding": [ | |
| 6782 | + { | |
| 6783 | + "type": "GitHub Sponsors", | |
| 6784 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6785 | + }, | |
| 6786 | + { | |
| 6787 | + "type": "OpenCollective", | |
| 6788 | + "url": "https://opencollective.com/unified" | |
| 6789 | + } | |
| 6790 | + ], | |
| 6791 | + "license": "MIT", | |
| 6792 | + "dependencies": { | |
| 6793 | + "micromark-util-symbol": "^2.0.0" | |
| 6794 | + } | |
| 6795 | + }, | |
| 6796 | + "node_modules/micromark-util-resolve-all": { | |
| 6797 | + "version": "2.0.1", | |
| 6798 | + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", | |
| 6799 | + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", | |
| 6800 | + "funding": [ | |
| 6801 | + { | |
| 6802 | + "type": "GitHub Sponsors", | |
| 6803 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6804 | + }, | |
| 6805 | + { | |
| 6806 | + "type": "OpenCollective", | |
| 6807 | + "url": "https://opencollective.com/unified" | |
| 6808 | + } | |
| 6809 | + ], | |
| 6810 | + "license": "MIT", | |
| 6811 | + "dependencies": { | |
| 6812 | + "micromark-util-types": "^2.0.0" | |
| 6813 | + } | |
| 6814 | + }, | |
| 6815 | + "node_modules/micromark-util-sanitize-uri": { | |
| 6816 | + "version": "2.0.1", | |
| 6817 | + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", | |
| 6818 | + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", | |
| 6819 | + "funding": [ | |
| 6820 | + { | |
| 6821 | + "type": "GitHub Sponsors", | |
| 6822 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6823 | + }, | |
| 6824 | + { | |
| 6825 | + "type": "OpenCollective", | |
| 6826 | + "url": "https://opencollective.com/unified" | |
| 6827 | + } | |
| 6828 | + ], | |
| 6829 | + "license": "MIT", | |
| 6830 | + "dependencies": { | |
| 6831 | + "micromark-util-character": "^2.0.0", | |
| 6832 | + "micromark-util-encode": "^2.0.0", | |
| 6833 | + "micromark-util-symbol": "^2.0.0" | |
| 6834 | + } | |
| 6835 | + }, | |
| 6836 | + "node_modules/micromark-util-subtokenize": { | |
| 6837 | + "version": "2.1.0", | |
| 6838 | + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", | |
| 6839 | + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", | |
| 6840 | + "funding": [ | |
| 6841 | + { | |
| 6842 | + "type": "GitHub Sponsors", | |
| 6843 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6844 | + }, | |
| 6845 | + { | |
| 6846 | + "type": "OpenCollective", | |
| 6847 | + "url": "https://opencollective.com/unified" | |
| 6848 | + } | |
| 6849 | + ], | |
| 6850 | + "license": "MIT", | |
| 6851 | + "dependencies": { | |
| 6852 | + "devlop": "^1.0.0", | |
| 6853 | + "micromark-util-chunked": "^2.0.0", | |
| 6854 | + "micromark-util-symbol": "^2.0.0", | |
| 6855 | + "micromark-util-types": "^2.0.0" | |
| 6856 | + } | |
| 6857 | + }, | |
| 6858 | + "node_modules/micromark-util-symbol": { | |
| 6859 | + "version": "2.0.1", | |
| 6860 | + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", | |
| 6861 | + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", | |
| 6862 | + "funding": [ | |
| 6863 | + { | |
| 6864 | + "type": "GitHub Sponsors", | |
| 6865 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6866 | + }, | |
| 6867 | + { | |
| 6868 | + "type": "OpenCollective", | |
| 6869 | + "url": "https://opencollective.com/unified" | |
| 6870 | + } | |
| 6871 | + ], | |
| 6872 | + "license": "MIT" | |
| 6873 | + }, | |
| 6874 | + "node_modules/micromark-util-types": { | |
| 6875 | + "version": "2.0.2", | |
| 6876 | + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", | |
| 6877 | + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", | |
| 6878 | + "funding": [ | |
| 6879 | + { | |
| 6880 | + "type": "GitHub Sponsors", | |
| 6881 | + "url": "https://github.com/sponsors/unifiedjs" | |
| 6882 | + }, | |
| 6883 | + { | |
| 6884 | + "type": "OpenCollective", | |
| 6885 | + "url": "https://opencollective.com/unified" | |
| 6886 | + } | |
| 6887 | + ], | |
| 6888 | + "license": "MIT" | |
| 6889 | + }, | |
| 6890 | + "node_modules/minimatch": { | |
| 6891 | + "version": "10.2.6", | |
| 6892 | + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", | |
| 6893 | + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", | |
| 6894 | + "dev": true, | |
| 6895 | + "license": "BlueOak-1.0.0", | |
| 6896 | + "dependencies": { | |
| 6897 | + "brace-expansion": "^5.0.8" | |
| 6898 | + }, | |
| 6899 | + "engines": { | |
| 6900 | + "node": "18 || 20 || >=22" | |
| 6901 | + }, | |
| 6902 | + "funding": { | |
| 6903 | + "url": "https://github.com/sponsors/isaacs" | |
| 6904 | + } | |
| 6905 | + }, | |
| 6906 | + "node_modules/minipass": { | |
| 6907 | + "version": "7.1.3", | |
| 6908 | + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", | |
| 6909 | + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", | |
| 6910 | + "dev": true, | |
| 6911 | + "license": "BlueOak-1.0.0", | |
| 6912 | + "engines": { | |
| 6913 | + "node": ">=16 || 14 >=14.17" | |
| 6914 | + } | |
| 6915 | + }, | |
| 6916 | + "node_modules/ms": { | |
| 6917 | + "version": "2.1.3", | |
| 6918 | + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |
| 6919 | + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", | |
| 6920 | + "license": "MIT" | |
| 6921 | + }, | |
| 6922 | + "node_modules/nanoid": { | |
| 6923 | + "version": "3.3.16", | |
| 6924 | + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", | |
| 6925 | + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", | |
| 6926 | + "dev": true, | |
| 6927 | + "funding": [ | |
| 6928 | + { | |
| 6929 | + "type": "github", | |
| 6930 | + "url": "https://github.com/sponsors/ai" | |
| 6931 | + } | |
| 6932 | + ], | |
| 6933 | + "license": "MIT", | |
| 6934 | + "bin": { | |
| 6935 | + "nanoid": "bin/nanoid.cjs" | |
| 6936 | + }, | |
| 6937 | + "engines": { | |
| 6938 | + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 6939 | + } | |
| 6940 | + }, | |
| 6941 | + "node_modules/natural-compare": { | |
| 6942 | + "version": "1.4.0", | |
| 6943 | + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", | |
| 6944 | + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", | |
| 6945 | + "dev": true, | |
| 5742 | 6946 | "license": "MIT" |
| 5743 | 6947 | }, |
| 5744 | 6948 | "node_modules/node-releases": { |
@@ -5884,6 +7088,31 @@ | ||
| 5884 | 7088 | "node": ">=6" |
| 5885 | 7089 | } |
| 5886 | 7090 | }, |
| 7091 | + "node_modules/parse-entities": { | |
| 7092 | + "version": "4.0.2", | |
| 7093 | + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", | |
| 7094 | + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", | |
| 7095 | + "license": "MIT", | |
| 7096 | + "dependencies": { | |
| 7097 | + "@types/unist": "^2.0.0", | |
| 7098 | + "character-entities-legacy": "^3.0.0", | |
| 7099 | + "character-reference-invalid": "^2.0.0", | |
| 7100 | + "decode-named-character-reference": "^1.0.0", | |
| 7101 | + "is-alphanumerical": "^2.0.0", | |
| 7102 | + "is-decimal": "^2.0.0", | |
| 7103 | + "is-hexadecimal": "^2.0.0" | |
| 7104 | + }, | |
| 7105 | + "funding": { | |
| 7106 | + "type": "github", | |
| 7107 | + "url": "https://github.com/sponsors/wooorm" | |
| 7108 | + } | |
| 7109 | + }, | |
| 7110 | + "node_modules/parse-entities/node_modules/@types/unist": { | |
| 7111 | + "version": "2.0.11", | |
| 7112 | + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", | |
| 7113 | + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", | |
| 7114 | + "license": "MIT" | |
| 7115 | + }, | |
| 5887 | 7116 | "node_modules/path-exists": { |
| 5888 | 7117 | "version": "4.0.0", |
| 5889 | 7118 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", |
@@ -6083,6 +7312,16 @@ | ||
| 6083 | 7312 | "url": "https://github.com/sponsors/sindresorhus" |
| 6084 | 7313 | } |
| 6085 | 7314 | }, |
| 7315 | + "node_modules/property-information": { | |
| 7316 | + "version": "7.2.0", | |
| 7317 | + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", | |
| 7318 | + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", | |
| 7319 | + "license": "MIT", | |
| 7320 | + "funding": { | |
| 7321 | + "type": "github", | |
| 7322 | + "url": "https://github.com/sponsors/wooorm" | |
| 7323 | + } | |
| 7324 | + }, | |
| 6086 | 7325 | "node_modules/punycode": { |
| 6087 | 7326 | "version": "2.3.1", |
| 6088 | 7327 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", |
@@ -6114,6 +7353,33 @@ | ||
| 6114 | 7353 | "react": "^19.2.8" |
| 6115 | 7354 | } |
| 6116 | 7355 | }, |
| 7356 | + "node_modules/react-markdown": { | |
| 7357 | + "version": "10.1.0", | |
| 7358 | + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", | |
| 7359 | + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", | |
| 7360 | + "license": "MIT", | |
| 7361 | + "dependencies": { | |
| 7362 | + "@types/hast": "^3.0.0", | |
| 7363 | + "@types/mdast": "^4.0.0", | |
| 7364 | + "devlop": "^1.0.0", | |
| 7365 | + "hast-util-to-jsx-runtime": "^2.0.0", | |
| 7366 | + "html-url-attributes": "^3.0.0", | |
| 7367 | + "mdast-util-to-hast": "^13.0.0", | |
| 7368 | + "remark-parse": "^11.0.0", | |
| 7369 | + "remark-rehype": "^11.0.0", | |
| 7370 | + "unified": "^11.0.0", | |
| 7371 | + "unist-util-visit": "^5.0.0", | |
| 7372 | + "vfile": "^6.0.0" | |
| 7373 | + }, | |
| 7374 | + "funding": { | |
| 7375 | + "type": "opencollective", | |
| 7376 | + "url": "https://opencollective.com/unified" | |
| 7377 | + }, | |
| 7378 | + "peerDependencies": { | |
| 7379 | + "@types/react": ">=18", | |
| 7380 | + "react": ">=18" | |
| 7381 | + } | |
| 7382 | + }, | |
| 6117 | 7383 | "node_modules/react-refresh": { |
| 6118 | 7384 | "version": "0.17.0", |
| 6119 | 7385 | "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", |
@@ -6226,6 +7492,89 @@ | ||
| 6226 | 7492 | "regjsparser": "bin/parser" |
| 6227 | 7493 | } |
| 6228 | 7494 | }, |
| 7495 | + "node_modules/rehype-highlight": { | |
| 7496 | + "version": "7.0.2", | |
| 7497 | + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", | |
| 7498 | + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", | |
| 7499 | + "license": "MIT", | |
| 7500 | + "dependencies": { | |
| 7501 | + "@types/hast": "^3.0.0", | |
| 7502 | + "hast-util-to-text": "^4.0.0", | |
| 7503 | + "lowlight": "^3.0.0", | |
| 7504 | + "unist-util-visit": "^5.0.0", | |
| 7505 | + "vfile": "^6.0.0" | |
| 7506 | + }, | |
| 7507 | + "funding": { | |
| 7508 | + "type": "opencollective", | |
| 7509 | + "url": "https://opencollective.com/unified" | |
| 7510 | + } | |
| 7511 | + }, | |
| 7512 | + "node_modules/remark-gfm": { | |
| 7513 | + "version": "4.0.1", | |
| 7514 | + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", | |
| 7515 | + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", | |
| 7516 | + "license": "MIT", | |
| 7517 | + "dependencies": { | |
| 7518 | + "@types/mdast": "^4.0.0", | |
| 7519 | + "mdast-util-gfm": "^3.0.0", | |
| 7520 | + "micromark-extension-gfm": "^3.0.0", | |
| 7521 | + "remark-parse": "^11.0.0", | |
| 7522 | + "remark-stringify": "^11.0.0", | |
| 7523 | + "unified": "^11.0.0" | |
| 7524 | + }, | |
| 7525 | + "funding": { | |
| 7526 | + "type": "opencollective", | |
| 7527 | + "url": "https://opencollective.com/unified" | |
| 7528 | + } | |
| 7529 | + }, | |
| 7530 | + "node_modules/remark-parse": { | |
| 7531 | + "version": "11.0.0", | |
| 7532 | + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", | |
| 7533 | + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", | |
| 7534 | + "license": "MIT", | |
| 7535 | + "dependencies": { | |
| 7536 | + "@types/mdast": "^4.0.0", | |
| 7537 | + "mdast-util-from-markdown": "^2.0.0", | |
| 7538 | + "micromark-util-types": "^2.0.0", | |
| 7539 | + "unified": "^11.0.0" | |
| 7540 | + }, | |
| 7541 | + "funding": { | |
| 7542 | + "type": "opencollective", | |
| 7543 | + "url": "https://opencollective.com/unified" | |
| 7544 | + } | |
| 7545 | + }, | |
| 7546 | + "node_modules/remark-rehype": { | |
| 7547 | + "version": "11.1.2", | |
| 7548 | + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", | |
| 7549 | + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", | |
| 7550 | + "license": "MIT", | |
| 7551 | + "dependencies": { | |
| 7552 | + "@types/hast": "^3.0.0", | |
| 7553 | + "@types/mdast": "^4.0.0", | |
| 7554 | + "mdast-util-to-hast": "^13.0.0", | |
| 7555 | + "unified": "^11.0.0", | |
| 7556 | + "vfile": "^6.0.0" | |
| 7557 | + }, | |
| 7558 | + "funding": { | |
| 7559 | + "type": "opencollective", | |
| 7560 | + "url": "https://opencollective.com/unified" | |
| 7561 | + } | |
| 7562 | + }, | |
| 7563 | + "node_modules/remark-stringify": { | |
| 7564 | + "version": "11.0.0", | |
| 7565 | + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", | |
| 7566 | + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", | |
| 7567 | + "license": "MIT", | |
| 7568 | + "dependencies": { | |
| 7569 | + "@types/mdast": "^4.0.0", | |
| 7570 | + "mdast-util-to-markdown": "^2.0.0", | |
| 7571 | + "unified": "^11.0.0" | |
| 7572 | + }, | |
| 7573 | + "funding": { | |
| 7574 | + "type": "opencollective", | |
| 7575 | + "url": "https://opencollective.com/unified" | |
| 7576 | + } | |
| 7577 | + }, | |
| 6229 | 7578 | "node_modules/require-from-string": { |
| 6230 | 7579 | "version": "2.0.2", |
| 6231 | 7580 | "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", |
@@ -6609,6 +7958,16 @@ | ||
| 6609 | 7958 | "node": ">=0.10.0" |
| 6610 | 7959 | } |
| 6611 | 7960 | }, |
| 7961 | + "node_modules/space-separated-tokens": { | |
| 7962 | + "version": "2.0.2", | |
| 7963 | + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", | |
| 7964 | + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", | |
| 7965 | + "license": "MIT", | |
| 7966 | + "funding": { | |
| 7967 | + "type": "github", | |
| 7968 | + "url": "https://github.com/sponsors/wooorm" | |
| 7969 | + } | |
| 7970 | + }, | |
| 6612 | 7971 | "node_modules/stop-iteration-iterator": { |
| 6613 | 7972 | "version": "1.1.0", |
| 6614 | 7973 | "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", |
@@ -6711,6 +8070,20 @@ | ||
| 6711 | 8070 | "url": "https://github.com/sponsors/ljharb" |
| 6712 | 8071 | } |
| 6713 | 8072 | }, |
| 8073 | + "node_modules/stringify-entities": { | |
| 8074 | + "version": "4.0.4", | |
| 8075 | + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", | |
| 8076 | + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", | |
| 8077 | + "license": "MIT", | |
| 8078 | + "dependencies": { | |
| 8079 | + "character-entities-html4": "^2.0.0", | |
| 8080 | + "character-entities-legacy": "^3.0.0" | |
| 8081 | + }, | |
| 8082 | + "funding": { | |
| 8083 | + "type": "github", | |
| 8084 | + "url": "https://github.com/sponsors/wooorm" | |
| 8085 | + } | |
| 8086 | + }, | |
| 6714 | 8087 | "node_modules/stringify-object": { |
| 6715 | 8088 | "version": "3.3.0", |
| 6716 | 8089 | "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", |
@@ -6749,6 +8122,24 @@ | ||
| 6749 | 8122 | "url": "https://github.com/sponsors/sindresorhus" |
| 6750 | 8123 | } |
| 6751 | 8124 | }, |
| 8125 | + "node_modules/style-to-js": { | |
| 8126 | + "version": "1.1.21", | |
| 8127 | + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", | |
| 8128 | + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", | |
| 8129 | + "license": "MIT", | |
| 8130 | + "dependencies": { | |
| 8131 | + "style-to-object": "1.0.14" | |
| 8132 | + } | |
| 8133 | + }, | |
| 8134 | + "node_modules/style-to-object": { | |
| 8135 | + "version": "1.0.14", | |
| 8136 | + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", | |
| 8137 | + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", | |
| 8138 | + "license": "MIT", | |
| 8139 | + "dependencies": { | |
| 8140 | + "inline-style-parser": "0.2.7" | |
| 8141 | + } | |
| 8142 | + }, | |
| 6752 | 8143 | "node_modules/supports-color": { |
| 6753 | 8144 | "version": "7.2.0", |
| 6754 | 8145 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", |
@@ -6840,6 +8231,26 @@ | ||
| 6840 | 8231 | "url": "https://github.com/sponsors/SuperchupuDev" |
| 6841 | 8232 | } |
| 6842 | 8233 | }, |
| 8234 | + "node_modules/trim-lines": { | |
| 8235 | + "version": "3.0.1", | |
| 8236 | + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", | |
| 8237 | + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", | |
| 8238 | + "license": "MIT", | |
| 8239 | + "funding": { | |
| 8240 | + "type": "github", | |
| 8241 | + "url": "https://github.com/sponsors/wooorm" | |
| 8242 | + } | |
| 8243 | + }, | |
| 8244 | + "node_modules/trough": { | |
| 8245 | + "version": "2.2.0", | |
| 8246 | + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", | |
| 8247 | + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", | |
| 8248 | + "license": "MIT", | |
| 8249 | + "funding": { | |
| 8250 | + "type": "github", | |
| 8251 | + "url": "https://github.com/sponsors/wooorm" | |
| 8252 | + } | |
| 8253 | + }, | |
| 6843 | 8254 | "node_modules/ts-api-utils": { |
| 6844 | 8255 | "version": "2.5.0", |
| 6845 | 8256 | "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", |
@@ -7034,6 +8445,25 @@ | ||
| 7034 | 8445 | "node": ">=4" |
| 7035 | 8446 | } |
| 7036 | 8447 | }, |
| 8448 | + "node_modules/unified": { | |
| 8449 | + "version": "11.0.5", | |
| 8450 | + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", | |
| 8451 | + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", | |
| 8452 | + "license": "MIT", | |
| 8453 | + "dependencies": { | |
| 8454 | + "@types/unist": "^3.0.0", | |
| 8455 | + "bail": "^2.0.0", | |
| 8456 | + "devlop": "^1.0.0", | |
| 8457 | + "extend": "^3.0.0", | |
| 8458 | + "is-plain-obj": "^4.0.0", | |
| 8459 | + "trough": "^2.0.0", | |
| 8460 | + "vfile": "^6.0.0" | |
| 8461 | + }, | |
| 8462 | + "funding": { | |
| 8463 | + "type": "opencollective", | |
| 8464 | + "url": "https://opencollective.com/unified" | |
| 8465 | + } | |
| 8466 | + }, | |
| 7037 | 8467 | "node_modules/unique-string": { |
| 7038 | 8468 | "version": "2.0.0", |
| 7039 | 8469 | "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", |
@@ -7047,6 +8477,88 @@ | ||
| 7047 | 8477 | "node": ">=8" |
| 7048 | 8478 | } |
| 7049 | 8479 | }, |
| 8480 | + "node_modules/unist-util-find-after": { | |
| 8481 | + "version": "5.0.0", | |
| 8482 | + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", | |
| 8483 | + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", | |
| 8484 | + "license": "MIT", | |
| 8485 | + "dependencies": { | |
| 8486 | + "@types/unist": "^3.0.0", | |
| 8487 | + "unist-util-is": "^6.0.0" | |
| 8488 | + }, | |
| 8489 | + "funding": { | |
| 8490 | + "type": "opencollective", | |
| 8491 | + "url": "https://opencollective.com/unified" | |
| 8492 | + } | |
| 8493 | + }, | |
| 8494 | + "node_modules/unist-util-is": { | |
| 8495 | + "version": "6.0.1", | |
| 8496 | + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", | |
| 8497 | + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", | |
| 8498 | + "license": "MIT", | |
| 8499 | + "dependencies": { | |
| 8500 | + "@types/unist": "^3.0.0" | |
| 8501 | + }, | |
| 8502 | + "funding": { | |
| 8503 | + "type": "opencollective", | |
| 8504 | + "url": "https://opencollective.com/unified" | |
| 8505 | + } | |
| 8506 | + }, | |
| 8507 | + "node_modules/unist-util-position": { | |
| 8508 | + "version": "5.0.0", | |
| 8509 | + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", | |
| 8510 | + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", | |
| 8511 | + "license": "MIT", | |
| 8512 | + "dependencies": { | |
| 8513 | + "@types/unist": "^3.0.0" | |
| 8514 | + }, | |
| 8515 | + "funding": { | |
| 8516 | + "type": "opencollective", | |
| 8517 | + "url": "https://opencollective.com/unified" | |
| 8518 | + } | |
| 8519 | + }, | |
| 8520 | + "node_modules/unist-util-stringify-position": { | |
| 8521 | + "version": "4.0.0", | |
| 8522 | + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", | |
| 8523 | + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", | |
| 8524 | + "license": "MIT", | |
| 8525 | + "dependencies": { | |
| 8526 | + "@types/unist": "^3.0.0" | |
| 8527 | + }, | |
| 8528 | + "funding": { | |
| 8529 | + "type": "opencollective", | |
| 8530 | + "url": "https://opencollective.com/unified" | |
| 8531 | + } | |
| 8532 | + }, | |
| 8533 | + "node_modules/unist-util-visit": { | |
| 8534 | + "version": "5.1.0", | |
| 8535 | + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", | |
| 8536 | + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", | |
| 8537 | + "license": "MIT", | |
| 8538 | + "dependencies": { | |
| 8539 | + "@types/unist": "^3.0.0", | |
| 8540 | + "unist-util-is": "^6.0.0", | |
| 8541 | + "unist-util-visit-parents": "^6.0.0" | |
| 8542 | + }, | |
| 8543 | + "funding": { | |
| 8544 | + "type": "opencollective", | |
| 8545 | + "url": "https://opencollective.com/unified" | |
| 8546 | + } | |
| 8547 | + }, | |
| 8548 | + "node_modules/unist-util-visit-parents": { | |
| 8549 | + "version": "6.0.2", | |
| 8550 | + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", | |
| 8551 | + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", | |
| 8552 | + "license": "MIT", | |
| 8553 | + "dependencies": { | |
| 8554 | + "@types/unist": "^3.0.0", | |
| 8555 | + "unist-util-is": "^6.0.0" | |
| 8556 | + }, | |
| 8557 | + "funding": { | |
| 8558 | + "type": "opencollective", | |
| 8559 | + "url": "https://opencollective.com/unified" | |
| 8560 | + } | |
| 8561 | + }, | |
| 7050 | 8562 | "node_modules/universalify": { |
| 7051 | 8563 | "version": "2.0.1", |
| 7052 | 8564 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", |
@@ -7109,6 +8621,34 @@ | ||
| 7109 | 8621 | "punycode": "^2.1.0" |
| 7110 | 8622 | } |
| 7111 | 8623 | }, |
| 8624 | + "node_modules/vfile": { | |
| 8625 | + "version": "6.0.3", | |
| 8626 | + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", | |
| 8627 | + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", | |
| 8628 | + "license": "MIT", | |
| 8629 | + "dependencies": { | |
| 8630 | + "@types/unist": "^3.0.0", | |
| 8631 | + "vfile-message": "^4.0.0" | |
| 8632 | + }, | |
| 8633 | + "funding": { | |
| 8634 | + "type": "opencollective", | |
| 8635 | + "url": "https://opencollective.com/unified" | |
| 8636 | + } | |
| 8637 | + }, | |
| 8638 | + "node_modules/vfile-message": { | |
| 8639 | + "version": "4.0.3", | |
| 8640 | + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", | |
| 8641 | + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", | |
| 8642 | + "license": "MIT", | |
| 8643 | + "dependencies": { | |
| 8644 | + "@types/unist": "^3.0.0", | |
| 8645 | + "unist-util-stringify-position": "^4.0.0" | |
| 8646 | + }, | |
| 8647 | + "funding": { | |
| 8648 | + "type": "opencollective", | |
| 8649 | + "url": "https://opencollective.com/unified" | |
| 8650 | + } | |
| 8651 | + }, | |
| 7112 | 8652 | "node_modules/vite": { |
| 7113 | 8653 | "version": "7.3.6", |
| 7114 | 8654 | "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", |
@@ -7639,6 +9179,16 @@ | ||
| 7639 | 9179 | "optional": true |
| 7640 | 9180 | } |
| 7641 | 9181 | } |
| 9182 | + }, | |
| 9183 | + "node_modules/zwitch": { | |
| 9184 | + "version": "2.0.4", | |
| 9185 | + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", | |
| 9186 | + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", | |
| 9187 | + "license": "MIT", | |
| 9188 | + "funding": { | |
| 9189 | + "type": "github", | |
| 9190 | + "url": "https://github.com/sponsors/wooorm" | |
| 9191 | + } | |
| 7642 | 9192 | } |
| 7643 | 9193 | } |
| 7644 | 9194 | } |
modified
package.json
+4 −0
@@ -14,8 +14,12 @@ | ||
| 14 | 14 | "check": "npm run typecheck && npm run lint && npm run build" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | + "highlight.js": "^11.11.1", | |
| 17 | 18 | "react": "^19.1.0", |
| 18 | 19 | "react-dom": "^19.1.0", |
| 20 | + "react-markdown": "^10.1.0", | |
| 21 | + "rehype-highlight": "^7.0.2", | |
| 22 | + "remark-gfm": "^4.0.1", | |
| 19 | 23 | "zustand": "^5.0.6" |
| 20 | 24 | }, |
| 21 | 25 | "devDependencies": { |
modified
src/App.tsx
+122 −18
@@ -4,31 +4,135 @@ | ||
| 4 | 4 | * |
| 5 | 5 | * Author: Simon-Pierre Boucher |
| 6 | 6 | * Mail: contact@spboucher.ai |
| 7 | + * | |
| 8 | + * The app shell: sidebar + chat area (drawer on phones), root empty state, | |
| 9 | + * first-run notice, settings modal, toasts, and global keyboard shortcuts | |
| 10 | + * (⌘N new chat, ⌘K model menu/palette, ⌘F search, ⌘, settings, ⌘. stop, | |
| 11 | + * ⌘⇧E export). | |
| 7 | 12 | */ |
| 8 | 13 | |
| 14 | +import { useEffect } from 'react' | |
| 15 | +import ChatView from './components/ChatView' | |
| 16 | +import FirstRun from './components/FirstRun' | |
| 17 | +import ModelMenu from './components/ModelMenu' | |
| 18 | +import SettingsModal from './components/SettingsModal' | |
| 19 | +import Sidebar from './components/Sidebar' | |
| 20 | +import SuggestionGrid from './components/SuggestionGrid' | |
| 21 | +import Toasts from './components/Toasts' | |
| 9 | 22 | import StreamTestView from './features/StreamTestView' |
| 23 | +import { exportConversationMarkdown } from './features/exporters' | |
| 24 | +import { useStore } from './state/store' | |
| 25 | +import './design/components.css' | |
| 10 | 26 | |
| 11 | −// Phase 1 placeholder shell — replaced by the real chat layout in Phase 4. | |
| 12 | −// #stream-test mounts the bare streaming view (Phase 2 gate; kept for Phase 7). | |
| 13 | 27 | export default function App() { |
| 28 | + const loaded = useStore((s) => s.loaded) | |
| 29 | + const loadAll = useStore((s) => s.loadAll) | |
| 30 | + const conversations = useStore((s) => s.conversations) | |
| 31 | + const selectedID = useStore((s) => s.selectedID) | |
| 32 | + const settings = useStore((s) => s.settings) | |
| 33 | + const settingsOpen = useStore((s) => s.settingsOpen) | |
| 34 | + const sidebarOpen = useStore((s) => s.sidebarOpen) | |
| 35 | + const setSidebarOpen = useStore((s) => s.setSidebarOpen) | |
| 36 | + const modelMenuOpen = useStore((s) => s.modelMenuOpen) | |
| 37 | + const setModelMenuOpen = useStore((s) => s.setModelMenuOpen) | |
| 38 | + const newConversation = useStore((s) => s.newConversation) | |
| 39 | + const openSettings = useStore((s) => s.openSettings) | |
| 40 | + const stop = useStore((s) => s.stop) | |
| 41 | + const send = useStore((s) => s.send) | |
| 42 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 43 | + | |
| 44 | + const selected = conversations.find((c) => c.id === selectedID) ?? null | |
| 45 | + | |
| 46 | + useEffect(() => { | |
| 47 | + void loadAll() | |
| 48 | + }, [loadAll]) | |
| 49 | + | |
| 50 | + // Global shortcuts | |
| 51 | + useEffect(() => { | |
| 52 | + const onKeyDown = (e: KeyboardEvent) => { | |
| 53 | + const mod = e.metaKey || e.ctrlKey | |
| 54 | + if (!mod) return | |
| 55 | + const state = useStore.getState() | |
| 56 | + switch (e.key) { | |
| 57 | + case 'n': | |
| 58 | + e.preventDefault() | |
| 59 | + state.newConversation() | |
| 60 | + break | |
| 61 | + case 'k': | |
| 62 | + e.preventDefault() | |
| 63 | + state.setModelMenuOpen(!state.modelMenuOpen) | |
| 64 | + break | |
| 65 | + case 'f': { | |
| 66 | + e.preventDefault() | |
| 67 | + const input = document.querySelector<HTMLInputElement>('[data-search-input]') | |
| 68 | + input?.focus() | |
| 69 | + break | |
| 70 | + } | |
| 71 | + case ',': | |
| 72 | + e.preventDefault() | |
| 73 | + state.openSettings() | |
| 74 | + break | |
| 75 | + case '.': | |
| 76 | + e.preventDefault() | |
| 77 | + if (state.selectedID) state.stop(state.selectedID) | |
| 78 | + break | |
| 79 | + case 'E': | |
| 80 | + if (e.shiftKey) { | |
| 81 | + e.preventDefault() | |
| 82 | + const conversation = state.conversations.find((c) => c.id === state.selectedID) | |
| 83 | + if (conversation) exportConversationMarkdown(conversation) | |
| 84 | + } | |
| 85 | + break | |
| 86 | + } | |
| 87 | + } | |
| 88 | + window.addEventListener('keydown', onKeyDown) | |
| 89 | + return () => window.removeEventListener('keydown', onKeyDown) | |
| 90 | + }, [newConversation, openSettings, stop]) | |
| 91 | + | |
| 14 | 92 | if (window.location.hash === '#stream-test') return <StreamTestView /> |
| 93 | + | |
| 94 | + if (!loaded) { | |
| 95 | + return ( | |
| 96 | + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}> | |
| 97 | + <span style={{ color: 'var(--z-text-tertiary)' }}>Loading…</span> | |
| 98 | + </div> | |
| 99 | + ) | |
| 100 | + } | |
| 101 | + | |
| 15 | 102 | return ( |
| 16 | − <div | |
| 17 | − style={{ | |
| 18 | − display: 'flex', | |
| 19 | − alignItems: 'center', | |
| 20 | − justifyContent: 'center', | |
| 21 | − height: '100%', | |
| 22 | − flexDirection: 'column', | |
| 23 | − gap: 'var(--z-space-sm)', | |
| 24 | − }} | |
| 25 | − > | |
| 26 | − <h1 style={{ font: '600 var(--z-font-size-title) var(--z-font-sans)', margin: 0 }}> | |
| 27 | − Zyquo Cloud | |
| 28 | − </h1> | |
| 29 | − <p style={{ color: 'var(--z-text-secondary)', margin: 0 }}> | |
| 30 | − Your keys, every cloud model, one beautiful chat. | |
| 31 | − </p> | |
| 103 | + <div className={`shell${settings.focusMode ? ' focus-mode' : ''}`}> | |
| 104 | + <Sidebar /> | |
| 105 | + {sidebarOpen && <div className="drawer-scrim" onClick={() => setSidebarOpen(false)} />} | |
| 106 | + {selected ? ( | |
| 107 | + <ChatView conversation={selected} /> | |
| 108 | + ) : ( | |
| 109 | + <div className="chat-area"> | |
| 110 | + <SuggestionGrid | |
| 111 | + onPick={(prompt) => { | |
| 112 | + const conversation = newConversation() | |
| 113 | + // Native behavior: self-contained suggestions send immediately; | |
| 114 | + // ones expecting pasted input (trailing blank line) seed the draft. | |
| 115 | + if (prompt.endsWith('\n\n')) { | |
| 116 | + useStore.getState().setDraftSeed(prompt) | |
| 117 | + } else { | |
| 118 | + void send(conversation.id, prompt, []) | |
| 119 | + } | |
| 120 | + }} | |
| 121 | + /> | |
| 122 | + {modelMenuOpen && ( | |
| 123 | + <ModelMenu | |
| 124 | + onPick={(model) => { | |
| 125 | + updateSettings({ defaultModelID: model.id, defaultProvider: model.provider }) | |
| 126 | + newConversation({ model }) | |
| 127 | + }} | |
| 128 | + onClose={() => setModelMenuOpen(false)} | |
| 129 | + /> | |
| 130 | + )} | |
| 131 | + </div> | |
| 132 | + )} | |
| 133 | + {settingsOpen && <SettingsModal />} | |
| 134 | + {!settings.firstRunAcknowledged && <FirstRun />} | |
| 135 | + <Toasts /> | |
| 32 | 136 | </div> |
| 33 | 137 | ) |
| 34 | 138 | } |
added
src/components/ChatView.tsx
+277 −0
@@ -0,0 +1,277 @@ | ||
| 1 | +/* | |
| 2 | + * ChatView.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * The chat area: header (editable title, centered model chip, actions), | |
| 9 | + * transcript with scroll-lock + "jump to latest" pill, input bar, and the | |
| 10 | + * parameters popover. Suggestions grid when the conversation is empty. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { useEffect, useMemo, useRef, useState } from 'react' | |
| 14 | +import { findModel } from '../features/catalogHelpers' | |
| 15 | +import { useStore } from '../state/store' | |
| 16 | +import type { AIModel, Conversation } from '../types' | |
| 17 | +import InputBar from './InputBar' | |
| 18 | +import { MessageBubble } from './MessageBubble' | |
| 19 | +import ModelMenu from './ModelMenu' | |
| 20 | +import ParamsPanel from './ParamsPanel' | |
| 21 | +import SuggestionGrid from './SuggestionGrid' | |
| 22 | +import { | |
| 23 | + IconArrowDown, | |
| 24 | + IconChevronDown, | |
| 25 | + IconColumns, | |
| 26 | + IconDownload, | |
| 27 | + IconInfo, | |
| 28 | + IconMenu, | |
| 29 | + ProviderGlyph, | |
| 30 | +} from './icons' | |
| 31 | +import { exportConversationMarkdown } from '../features/exporters' | |
| 32 | + | |
| 33 | +export default function ChatView({ conversation }: { conversation: Conversation }) { | |
| 34 | + const updateConversation = useStore((s) => s.updateConversation) | |
| 35 | + const streaming = useStore((s) => s.streaming[conversation.id]) | |
| 36 | + const setSidebarOpen = useStore((s) => s.setSidebarOpen) | |
| 37 | + const setCompareOpen = useStore((s) => s.setCompareOpen) | |
| 38 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 39 | + const modelMenuOpen = useStore((s) => s.modelMenuOpen) | |
| 40 | + const setModelMenuOpen = useStore((s) => s.setModelMenuOpen) | |
| 41 | + const density = useStore((s) => s.settings.density) | |
| 42 | + | |
| 43 | + const [editingTitle, setEditingTitle] = useState(false) | |
| 44 | + const [titleDraft, setTitleDraft] = useState(conversation.title) | |
| 45 | + const [paramsOpen, setParamsOpen] = useState(false) | |
| 46 | + const [infoOpen, setInfoOpen] = useState(false) | |
| 47 | + const draftSeed = useStore((s) => s.draftSeed) | |
| 48 | + const setDraftSeed = useStore((s) => s.setDraftSeed) | |
| 49 | + const [draft, setDraft] = useState('') | |
| 50 | + const [pinnedToBottom, setPinnedToBottom] = useState(true) | |
| 51 | + | |
| 52 | + // Pick up a prompt seeded from the root empty state's suggestion cards. | |
| 53 | + useEffect(() => { | |
| 54 | + if (draftSeed !== null) { | |
| 55 | + setDraft(draftSeed) | |
| 56 | + setDraftSeed(null) | |
| 57 | + document.querySelector<HTMLTextAreaElement>('[data-input-editor]')?.focus() | |
| 58 | + } | |
| 59 | + }, [draftSeed, setDraftSeed]) | |
| 60 | + const scrollRef = useRef<HTMLDivElement>(null) | |
| 61 | + | |
| 62 | + const model = findModel(conversation.provider, conversation.modelID) | |
| 63 | + | |
| 64 | + // Auto-scroll while pinned to bottom. | |
| 65 | + const lastMessage = conversation.messages[conversation.messages.length - 1] | |
| 66 | + const scrollFingerprint = `${conversation.messages.length}:${lastMessage?.text.length ?? 0}:${ | |
| 67 | + lastMessage?.reasoning?.length ?? 0 | |
| 68 | + }` | |
| 69 | + useEffect(() => { | |
| 70 | + if (pinnedToBottom && scrollRef.current) { | |
| 71 | + scrollRef.current.scrollTop = scrollRef.current.scrollHeight | |
| 72 | + } | |
| 73 | + }, [scrollFingerprint, pinnedToBottom]) | |
| 74 | + | |
| 75 | + const onScroll = () => { | |
| 76 | + const el = scrollRef.current | |
| 77 | + if (!el) return | |
| 78 | + setPinnedToBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60) | |
| 79 | + } | |
| 80 | + | |
| 81 | + const totals = useMemo(() => { | |
| 82 | + let input = 0 | |
| 83 | + let output = 0 | |
| 84 | + let cost = 0 | |
| 85 | + for (const message of conversation.messages) { | |
| 86 | + input += message.usage?.inputTokens ?? 0 | |
| 87 | + output += message.usage?.outputTokens ?? 0 | |
| 88 | + cost += message.estimatedCost ?? 0 | |
| 89 | + } | |
| 90 | + return { input, output, cost } | |
| 91 | + }, [conversation]) | |
| 92 | + | |
| 93 | + const pickModel = (picked: AIModel, scope: 'conversation' | 'default' | 'message') => { | |
| 94 | + if (scope === 'default') { | |
| 95 | + updateSettings({ defaultModelID: picked.id, defaultProvider: picked.provider }) | |
| 96 | + } | |
| 97 | + updateConversation(conversation.id, { modelID: picked.id, provider: picked.provider }) | |
| 98 | + } | |
| 99 | + | |
| 100 | + const quote = (text: string) => { | |
| 101 | + const quoted = text | |
| 102 | + .split('\n') | |
| 103 | + .map((line) => `> ${line}`) | |
| 104 | + .join('\n') | |
| 105 | + setDraft((d) => `${quoted}\n\n${d}`) | |
| 106 | + } | |
| 107 | + | |
| 108 | + return ( | |
| 109 | + <div className={`chat-area density-${density}`}> | |
| 110 | + <header className="chat-header"> | |
| 111 | + <div className="chat-header-left"> | |
| 112 | + <button | |
| 113 | + className="icon-button menu-button" | |
| 114 | + title="Conversations" | |
| 115 | + onClick={() => setSidebarOpen(true)} | |
| 116 | + > | |
| 117 | + <IconMenu /> | |
| 118 | + </button> | |
| 119 | + {editingTitle ? ( | |
| 120 | + <input | |
| 121 | + className="chat-title-input" | |
| 122 | + value={titleDraft} | |
| 123 | + autoFocus | |
| 124 | + onChange={(e) => setTitleDraft(e.target.value)} | |
| 125 | + onBlur={() => { | |
| 126 | + setEditingTitle(false) | |
| 127 | + if (titleDraft.trim() !== '') { | |
| 128 | + updateConversation(conversation.id, { | |
| 129 | + title: titleDraft.trim(), | |
| 130 | + hasAutoTitle: false, | |
| 131 | + }) | |
| 132 | + } | |
| 133 | + }} | |
| 134 | + onKeyDown={(e) => { | |
| 135 | + if (e.key === 'Enter') (e.target as HTMLInputElement).blur() | |
| 136 | + if (e.key === 'Escape') setEditingTitle(false) | |
| 137 | + }} | |
| 138 | + /> | |
| 139 | + ) : ( | |
| 140 | + <span | |
| 141 | + className="chat-title" | |
| 142 | + title="Double-click to rename" | |
| 143 | + onDoubleClick={() => { | |
| 144 | + setTitleDraft(conversation.title) | |
| 145 | + setEditingTitle(true) | |
| 146 | + }} | |
| 147 | + > | |
| 148 | + {conversation.title} | |
| 149 | + </span> | |
| 150 | + )} | |
| 151 | + </div> | |
| 152 | + <button className="model-chip" onClick={() => setModelMenuOpen(true)} data-model-chip> | |
| 153 | + <span className="glyph"> | |
| 154 | + <ProviderGlyph provider={conversation.provider} /> | |
| 155 | + </span> | |
| 156 | + {model?.displayName ?? conversation.modelID} | |
| 157 | + <span className="chevron"> | |
| 158 | + <IconChevronDown /> | |
| 159 | + </span> | |
| 160 | + </button> | |
| 161 | + <div className="chat-header-right"> | |
| 162 | + <button className="icon-button" title="Compare models" onClick={() => setCompareOpen(true)}> | |
| 163 | + <IconColumns /> | |
| 164 | + </button> | |
| 165 | + <button | |
| 166 | + className="icon-button" | |
| 167 | + title="Export conversation (⌘⇧E)" | |
| 168 | + onClick={() => exportConversationMarkdown(conversation)} | |
| 169 | + > | |
| 170 | + <IconDownload /> | |
| 171 | + </button> | |
| 172 | + <button className="icon-button" title="Conversation info" onClick={() => setInfoOpen(true)}> | |
| 173 | + <IconInfo /> | |
| 174 | + </button> | |
| 175 | + </div> | |
| 176 | + </header> | |
| 177 | + | |
| 178 | + {conversation.messages.length === 0 ? ( | |
| 179 | + <SuggestionGrid | |
| 180 | + onPick={(prompt) => setDraft(prompt)} | |
| 181 | + modelChip={ | |
| 182 | + <button className="model-chip" onClick={() => setModelMenuOpen(true)}> | |
| 183 | + <span className="glyph"> | |
| 184 | + <ProviderGlyph provider={conversation.provider} /> | |
| 185 | + </span> | |
| 186 | + {model?.displayName ?? conversation.modelID} | |
| 187 | + <span className="chevron"> | |
| 188 | + <IconChevronDown /> | |
| 189 | + </span> | |
| 190 | + </button> | |
| 191 | + } | |
| 192 | + /> | |
| 193 | + ) : ( | |
| 194 | + <div className="transcript" ref={scrollRef} onScroll={onScroll}> | |
| 195 | + <div className="transcript-column"> | |
| 196 | + {conversation.messages.map((message) => ( | |
| 197 | + <MessageBubble | |
| 198 | + key={message.id} | |
| 199 | + conversationID={conversation.id} | |
| 200 | + message={message} | |
| 201 | + streaming={streaming?.messageID === message.id} | |
| 202 | + onQuote={quote} | |
| 203 | + /> | |
| 204 | + ))} | |
| 205 | + </div> | |
| 206 | + {!pinnedToBottom && streaming && ( | |
| 207 | + <button | |
| 208 | + className="jump-pill" | |
| 209 | + onClick={() => { | |
| 210 | + setPinnedToBottom(true) | |
| 211 | + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) | |
| 212 | + }} | |
| 213 | + > | |
| 214 | + <IconArrowDown /> Jump to latest | |
| 215 | + </button> | |
| 216 | + )} | |
| 217 | + </div> | |
| 218 | + )} | |
| 219 | + | |
| 220 | + <InputBar | |
| 221 | + conversation={conversation} | |
| 222 | + draft={draft} | |
| 223 | + setDraft={setDraft} | |
| 224 | + onOpenParams={() => setParamsOpen(true)} | |
| 225 | + /> | |
| 226 | + | |
| 227 | + {modelMenuOpen && ( | |
| 228 | + <ModelMenu | |
| 229 | + currentID={conversation.modelID} | |
| 230 | + onPick={pickModel} | |
| 231 | + onClose={() => setModelMenuOpen(false)} | |
| 232 | + /> | |
| 233 | + )} | |
| 234 | + {paramsOpen && ( | |
| 235 | + <ParamsPanel conversation={conversation} onClose={() => setParamsOpen(false)} /> | |
| 236 | + )} | |
| 237 | + {infoOpen && ( | |
| 238 | + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setInfoOpen(false)}> | |
| 239 | + <div className="panel" style={{ width: 400 }}> | |
| 240 | + <div className="settings-content"> | |
| 241 | + <h3>Conversation</h3> | |
| 242 | + <div className="settings-row"> | |
| 243 | + <span>Tokens</span> | |
| 244 | + <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}> | |
| 245 | + {totals.input.toLocaleString()} in · {totals.output.toLocaleString()} out | |
| 246 | + </span> | |
| 247 | + </div> | |
| 248 | + <div className="settings-row"> | |
| 249 | + <span>Estimated cost</span> | |
| 250 | + <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}> | |
| 251 | + ~${totals.cost.toFixed(4)} | |
| 252 | + </span> | |
| 253 | + </div> | |
| 254 | + <div className="settings-section" style={{ marginTop: 12 }}> | |
| 255 | + <h3>System prompt</h3> | |
| 256 | + <textarea | |
| 257 | + className="text-input" | |
| 258 | + rows={5} | |
| 259 | + placeholder="No system prompt" | |
| 260 | + value={conversation.systemPrompt ?? ''} | |
| 261 | + onChange={(e) => | |
| 262 | + updateConversation(conversation.id, { systemPrompt: e.target.value }) | |
| 263 | + } | |
| 264 | + /> | |
| 265 | + </div> | |
| 266 | + <div style={{ display: 'flex', justifyContent: 'flex-end' }}> | |
| 267 | + <button className="button" onClick={() => setInfoOpen(false)}> | |
| 268 | + Done | |
| 269 | + </button> | |
| 270 | + </div> | |
| 271 | + </div> | |
| 272 | + </div> | |
| 273 | + </div> | |
| 274 | + )} | |
| 275 | + </div> | |
| 276 | + ) | |
| 277 | +} | |
added
src/components/FirstRun.tsx
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +/* | |
| 2 | + * FirstRun.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * First-run welcome: clean, honest, transparent about local storage. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { useStore } from '../state/store' | |
| 12 | +import ZyquoGlyph from './ZyquoGlyph' | |
| 13 | + | |
| 14 | +export default function FirstRun() { | |
| 15 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 16 | + const openSettings = useStore((s) => s.openSettings) | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <div className="overlay"> | |
| 20 | + <div className="panel" style={{ width: 520 }}> | |
| 21 | + <div className="settings-content" style={{ padding: 24 }}> | |
| 22 | + <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}> | |
| 23 | + <ZyquoGlyph size={56} /> | |
| 24 | + </div> | |
| 25 | + <h3 style={{ textAlign: 'center', fontSize: 18 }}>Welcome to Zyquo Cloud</h3> | |
| 26 | + <p style={{ color: 'var(--z-text-secondary)', textAlign: 'center', marginTop: 4 }}> | |
| 27 | + Every cloud model, one beautiful chat — running entirely in your browser. | |
| 28 | + </p> | |
| 29 | + <div className="first-run-note" style={{ marginTop: 16 }}> | |
| 30 | + <span> | |
| 31 | + <strong>No account. No backend. Nothing leaves this device.</strong> | |
| 32 | + </span> | |
| 33 | + <span> | |
| 34 | + Your API keys and conversation history are stored <strong>in this browser</strong> | |
| 35 | + {' '}(localStorage / IndexedDB) and are sent only to the AI provider you choose, | |
| 36 | + directly. Keys in browser storage aren't a secure vault — anyone with access to this | |
| 37 | + browser profile could read them. An optional passphrase lock is available in | |
| 38 | + Settings → Advanced. | |
| 39 | + </span> | |
| 40 | + <span>Add a provider API key to begin.</span> | |
| 41 | + </div> | |
| 42 | + <div className="first-run-actions" style={{ marginTop: 16 }}> | |
| 43 | + <button | |
| 44 | + className="button" | |
| 45 | + onClick={() => updateSettings({ firstRunAcknowledged: true })} | |
| 46 | + > | |
| 47 | + Explore first | |
| 48 | + </button> | |
| 49 | + <button | |
| 50 | + className="button primary" | |
| 51 | + onClick={() => { | |
| 52 | + updateSettings({ firstRunAcknowledged: true }) | |
| 53 | + openSettings('providers') | |
| 54 | + }} | |
| 55 | + > | |
| 56 | + Add a key | |
| 57 | + </button> | |
| 58 | + </div> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + ) | |
| 63 | +} | |
added
src/components/InputBar.tsx
+249 −0
@@ -0,0 +1,249 @@ | ||
| 1 | +/* | |
| 2 | + * InputBar.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Floating input card: multiline auto-grow editor, image attach/paste for | |
| 9 | + * vision models, text-file attach, drag-and-drop, params popover trigger, | |
| 10 | + * circular accent send (⌘/Ctrl+Enter) that becomes Stop while streaming, | |
| 11 | + * token/cost HUD with context-window usage bar. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +import { useEffect, useMemo, useRef, useState } from 'react' | |
| 15 | +import { estimateTokens, findModel } from '../features/catalogHelpers' | |
| 16 | +import { useStore } from '../state/store' | |
| 17 | +import type { Attachment, Conversation } from '../types' | |
| 18 | +import { uid } from '../state/store' | |
| 19 | +import { IconPaperclip, IconSend, IconSliders, IconStop, IconX } from './icons' | |
| 20 | + | |
| 21 | +const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] | |
| 22 | +const MAX_TEXT_FILE = 512 * 1024 | |
| 23 | + | |
| 24 | +export default function InputBar({ | |
| 25 | + conversation, | |
| 26 | + draft, | |
| 27 | + setDraft, | |
| 28 | + onOpenParams, | |
| 29 | +}: { | |
| 30 | + conversation: Conversation | |
| 31 | + draft: string | |
| 32 | + setDraft: (text: string) => void | |
| 33 | + onOpenParams: () => void | |
| 34 | +}) { | |
| 35 | + const send = useStore((s) => s.send) | |
| 36 | + const stop = useStore((s) => s.stop) | |
| 37 | + const streaming = useStore((s) => s.streaming[conversation.id]) | |
| 38 | + const toast = useStore((s) => s.toast) | |
| 39 | + const [attachments, setAttachments] = useState<Attachment[]>([]) | |
| 40 | + const [dragging, setDragging] = useState(false) | |
| 41 | + const editorRef = useRef<HTMLTextAreaElement>(null) | |
| 42 | + const fileRef = useRef<HTMLInputElement>(null) | |
| 43 | + | |
| 44 | + const model = findModel(conversation.provider, conversation.modelID) | |
| 45 | + const vision = model?.capabilities.vision ?? false | |
| 46 | + | |
| 47 | + useEffect(() => { | |
| 48 | + const editor = editorRef.current | |
| 49 | + if (!editor) return | |
| 50 | + editor.style.height = 'auto' | |
| 51 | + editor.style.height = `${Math.min(editor.scrollHeight, 200)}px` | |
| 52 | + }, [draft]) | |
| 53 | + | |
| 54 | + const contextUsage = useMemo(() => { | |
| 55 | + if (!model) return 0 | |
| 56 | + let chars = (conversation.systemPrompt ?? '').length + draft.length | |
| 57 | + for (const message of conversation.messages) chars += message.text.length | |
| 58 | + return Math.min(1, estimateTokens('x'.repeat(chars)) / model.contextWindow) | |
| 59 | + }, [conversation, draft, model]) | |
| 60 | + | |
| 61 | + const addFiles = async (files: FileList | File[]) => { | |
| 62 | + for (const file of files) { | |
| 63 | + if (IMAGE_TYPES.includes(file.type)) { | |
| 64 | + if (!vision) { | |
| 65 | + toast(`${model?.displayName ?? 'This model'} doesn't support images`, 'error') | |
| 66 | + continue | |
| 67 | + } | |
| 68 | + const data = await fileToBase64(file) | |
| 69 | + setAttachments((prev) => [ | |
| 70 | + ...prev, | |
| 71 | + { id: uid(), kind: 'image', fileName: file.name, mimeType: file.type, data }, | |
| 72 | + ]) | |
| 73 | + } else if (file.size <= MAX_TEXT_FILE) { | |
| 74 | + const text = await file.text() | |
| 75 | + setAttachments((prev) => [ | |
| 76 | + ...prev, | |
| 77 | + { | |
| 78 | + id: uid(), | |
| 79 | + kind: 'textFile', | |
| 80 | + fileName: file.name, | |
| 81 | + mimeType: file.type || 'text/plain', | |
| 82 | + data: text, | |
| 83 | + }, | |
| 84 | + ]) | |
| 85 | + } else { | |
| 86 | + toast(`${file.name} is too large (max 512 KB for text files)`, 'error') | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + const doSend = () => { | |
| 92 | + const text = draft.trim() | |
| 93 | + if (text === '' && attachments.length === 0) return | |
| 94 | + setDraft('') | |
| 95 | + const toSend = attachments | |
| 96 | + setAttachments([]) | |
| 97 | + void send(conversation.id, text, toSend) | |
| 98 | + } | |
| 99 | + | |
| 100 | + const onKeyDown = (e: React.KeyboardEvent) => { | |
| 101 | + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { | |
| 102 | + e.preventDefault() | |
| 103 | + doSend() | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + const onPaste = (e: React.ClipboardEvent) => { | |
| 108 | + const files = [...e.clipboardData.files] | |
| 109 | + if (files.length > 0) { | |
| 110 | + e.preventDefault() | |
| 111 | + void addFiles(files) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + const totalTokens = useMemo(() => { | |
| 116 | + let tokens = 0 | |
| 117 | + let cost = 0 | |
| 118 | + for (const message of conversation.messages) { | |
| 119 | + tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) | |
| 120 | + cost += message.estimatedCost ?? 0 | |
| 121 | + } | |
| 122 | + return { tokens, cost } | |
| 123 | + }, [conversation]) | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <div className="input-dock"> | |
| 127 | + <div | |
| 128 | + className={`input-bar${dragging ? ' drag-target' : ''}`} | |
| 129 | + onDragOver={(e) => { | |
| 130 | + e.preventDefault() | |
| 131 | + setDragging(true) | |
| 132 | + }} | |
| 133 | + onDragLeave={() => setDragging(false)} | |
| 134 | + onDrop={(e) => { | |
| 135 | + e.preventDefault() | |
| 136 | + setDragging(false) | |
| 137 | + void addFiles(e.dataTransfer.files) | |
| 138 | + }} | |
| 139 | + > | |
| 140 | + {attachments.length > 0 && ( | |
| 141 | + <div className="attachment-strip"> | |
| 142 | + {attachments.map((attachment) => ( | |
| 143 | + <div key={attachment.id} className="attachment-thumb"> | |
| 144 | + {attachment.kind === 'image' ? ( | |
| 145 | + <img | |
| 146 | + src={`data:${attachment.mimeType};base64,${attachment.data}`} | |
| 147 | + alt={attachment.fileName} | |
| 148 | + /> | |
| 149 | + ) : ( | |
| 150 | + <span>{attachment.fileName}</span> | |
| 151 | + )} | |
| 152 | + <button | |
| 153 | + className="attachment-remove" | |
| 154 | + onClick={() => | |
| 155 | + setAttachments((prev) => prev.filter((a) => a.id !== attachment.id)) | |
| 156 | + } | |
| 157 | + > | |
| 158 | + <IconX size={8} /> | |
| 159 | + </button> | |
| 160 | + </div> | |
| 161 | + ))} | |
| 162 | + </div> | |
| 163 | + )} | |
| 164 | + <div className="input-row"> | |
| 165 | + <button | |
| 166 | + className="icon-button" | |
| 167 | + title={vision ? 'Attach image or file' : 'Attach text file'} | |
| 168 | + onClick={() => fileRef.current?.click()} | |
| 169 | + > | |
| 170 | + <IconPaperclip /> | |
| 171 | + </button> | |
| 172 | + <input | |
| 173 | + ref={fileRef} | |
| 174 | + type="file" | |
| 175 | + multiple | |
| 176 | + hidden | |
| 177 | + onChange={(e) => { | |
| 178 | + if (e.target.files) void addFiles(e.target.files) | |
| 179 | + e.target.value = '' | |
| 180 | + }} | |
| 181 | + /> | |
| 182 | + <textarea | |
| 183 | + ref={editorRef} | |
| 184 | + className="input-editor" | |
| 185 | + placeholder="Message…" | |
| 186 | + rows={1} | |
| 187 | + value={draft} | |
| 188 | + data-input-editor | |
| 189 | + onChange={(e) => setDraft(e.target.value)} | |
| 190 | + onKeyDown={onKeyDown} | |
| 191 | + onPaste={onPaste} | |
| 192 | + /> | |
| 193 | + <button className="icon-button" title="Parameters" onClick={onOpenParams}> | |
| 194 | + <IconSliders /> | |
| 195 | + </button> | |
| 196 | + {streaming ? ( | |
| 197 | + <button | |
| 198 | + className="send-button stop" | |
| 199 | + title="Stop (⌘.)" | |
| 200 | + onClick={() => stop(conversation.id)} | |
| 201 | + > | |
| 202 | + <IconStop /> | |
| 203 | + </button> | |
| 204 | + ) : ( | |
| 205 | + <button | |
| 206 | + className="send-button" | |
| 207 | + title="Send (⌘↩)" | |
| 208 | + disabled={draft.trim() === '' && attachments.length === 0} | |
| 209 | + onClick={doSend} | |
| 210 | + > | |
| 211 | + <IconSend /> | |
| 212 | + </button> | |
| 213 | + )} | |
| 214 | + </div> | |
| 215 | + <div className="input-hud"> | |
| 216 | + {model && ( | |
| 217 | + <> | |
| 218 | + <div className="context-bar" title="Context window usage"> | |
| 219 | + <div | |
| 220 | + className={`context-bar-fill${contextUsage > 0.8 ? ' warn' : ''}`} | |
| 221 | + style={{ width: `${Math.max(2, contextUsage * 100)}%` }} | |
| 222 | + /> | |
| 223 | + </div> | |
| 224 | + <span>{Math.round(contextUsage * 100)}% ctx</span> | |
| 225 | + </> | |
| 226 | + )} | |
| 227 | + {totalTokens.tokens > 0 && ( | |
| 228 | + <span> | |
| 229 | + {totalTokens.tokens.toLocaleString()} tok | |
| 230 | + {totalTokens.cost > 0 ? ` · ~$${totalTokens.cost.toFixed(4)}` : ''} | |
| 231 | + </span> | |
| 232 | + )} | |
| 233 | + </div> | |
| 234 | + </div> | |
| 235 | + </div> | |
| 236 | + ) | |
| 237 | +} | |
| 238 | + | |
| 239 | +function fileToBase64(file: File): Promise<string> { | |
| 240 | + return new Promise((resolve, reject) => { | |
| 241 | + const reader = new FileReader() | |
| 242 | + reader.onload = () => { | |
| 243 | + const result = reader.result as string | |
| 244 | + resolve(result.slice(result.indexOf(',') + 1)) | |
| 245 | + } | |
| 246 | + reader.onerror = () => reject(reader.error ?? new Error('read failed')) | |
| 247 | + reader.readAsDataURL(file) | |
| 248 | + }) | |
| 249 | +} | |
added
src/components/Markdown.tsx
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +/* | |
| 2 | + * Markdown.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Assistant message rendering: GitHub-flavored Markdown with syntax- | |
| 9 | + * highlighted code blocks (language label, copy, wrap toggle) mapped to the | |
| 10 | + * Zyquo syntax tokens. Memoized — re-parsing only when text changes. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { memo, useState, type ReactNode } from 'react' | |
| 14 | +import ReactMarkdown from 'react-markdown' | |
| 15 | +import remarkGfm from 'remark-gfm' | |
| 16 | +import rehypeHighlight from 'rehype-highlight' | |
| 17 | +import { IconCheck, IconCopy } from './icons' | |
| 18 | + | |
| 19 | +function extractText(node: ReactNode): string { | |
| 20 | + if (typeof node === 'string') return node | |
| 21 | + if (Array.isArray(node)) return node.map(extractText).join('') | |
| 22 | + if (node && typeof node === 'object' && 'props' in node) { | |
| 23 | + return extractText((node as { props: { children?: ReactNode } }).props.children) | |
| 24 | + } | |
| 25 | + return '' | |
| 26 | +} | |
| 27 | + | |
| 28 | +function CodeBlock({ className, children }: { className?: string; children?: ReactNode }) { | |
| 29 | + const [copied, setCopied] = useState(false) | |
| 30 | + const [wrap, setWrap] = useState(false) | |
| 31 | + const language = /language-(\w+)/.exec(className ?? '')?.[1] ?? '' | |
| 32 | + | |
| 33 | + const copy = () => { | |
| 34 | + void navigator.clipboard.writeText(extractText(children).replace(/\n$/, '')) | |
| 35 | + setCopied(true) | |
| 36 | + setTimeout(() => setCopied(false), 1200) | |
| 37 | + } | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <div className="code-block"> | |
| 41 | + <div className="code-block-header"> | |
| 42 | + <span>{language || 'text'}</span> | |
| 43 | + <span className={`code-block-actions${copied ? ' confirming' : ''}`}> | |
| 44 | + <button className="code-block-copy" onClick={() => setWrap((w) => !w)}> | |
| 45 | + {wrap ? 'unwrap' : 'wrap'} | |
| 46 | + </button> | |
| 47 | + <button className={`code-block-copy${copied ? ' ok' : ''}`} onClick={copy}> | |
| 48 | + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />} {copied ? 'copied' : 'copy'} | |
| 49 | + </button> | |
| 50 | + </span> | |
| 51 | + </div> | |
| 52 | + <pre className={wrap ? 'wrap' : ''}> | |
| 53 | + <code className={className}>{children}</code> | |
| 54 | + </pre> | |
| 55 | + </div> | |
| 56 | + ) | |
| 57 | +} | |
| 58 | + | |
| 59 | +export const Markdown = memo(function Markdown({ text }: { text: string }) { | |
| 60 | + return ( | |
| 61 | + <div className="markdown"> | |
| 62 | + <ReactMarkdown | |
| 63 | + remarkPlugins={[remarkGfm]} | |
| 64 | + rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]} | |
| 65 | + components={{ | |
| 66 | + pre: ({ children }) => <>{children}</>, | |
| 67 | + code: ({ className, children, ...props }) => { | |
| 68 | + const inline = !className && typeof children === 'string' && !children.includes('\n') | |
| 69 | + if (inline) { | |
| 70 | + return ( | |
| 71 | + <code className={className} {...props}> | |
| 72 | + {children} | |
| 73 | + </code> | |
| 74 | + ) | |
| 75 | + } | |
| 76 | + return <CodeBlock className={className}>{children}</CodeBlock> | |
| 77 | + }, | |
| 78 | + a: ({ href, children }) => ( | |
| 79 | + <a href={href} target="_blank" rel="noreferrer noopener"> | |
| 80 | + {children} | |
| 81 | + </a> | |
| 82 | + ), | |
| 83 | + }} | |
| 84 | + > | |
| 85 | + {text} | |
| 86 | + </ReactMarkdown> | |
| 87 | + </div> | |
| 88 | + ) | |
| 89 | +}) | |
added
src/components/MessageBubble.tsx
+305 −0
@@ -0,0 +1,305 @@ | ||
| 1 | +/* | |
| 2 | + * MessageBubble.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * One chat turn: user right (accent-subtle) / assistant left (surface + | |
| 9 | + * hairline + provider avatar). Markdown body, collapsible reasoning, | |
| 10 | + * citation chips, attachments, error note, hover metadata + actions | |
| 11 | + * (copy, edit & resend, regenerate, quote, branch, read-aloud, delete), | |
| 12 | + * variant switcher, streaming caret. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +import { memo, useState } from 'react' | |
| 16 | +import { costLabel } from '../features/catalogHelpers' | |
| 17 | +import { useStore } from '../state/store' | |
| 18 | +import type { Message } from '../types' | |
| 19 | +import { Markdown } from './Markdown' | |
| 20 | +import { | |
| 21 | + IconBranch, | |
| 22 | + IconCheck, | |
| 23 | + IconChevronRight, | |
| 24 | + IconCopy, | |
| 25 | + IconEdit, | |
| 26 | + IconQuote, | |
| 27 | + IconRefresh, | |
| 28 | + IconSpeaker, | |
| 29 | + IconTrash, | |
| 30 | + IconX, | |
| 31 | + ProviderGlyph, | |
| 32 | +} from './icons' | |
| 33 | + | |
| 34 | +function timeLabel(timestamp: number): string { | |
| 35 | + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const MessageBubble = memo(function MessageBubble({ | |
| 39 | + conversationID, | |
| 40 | + message, | |
| 41 | + streaming, | |
| 42 | + onQuote, | |
| 43 | +}: { | |
| 44 | + conversationID: string | |
| 45 | + message: Message | |
| 46 | + streaming: boolean | |
| 47 | + onQuote: (text: string) => void | |
| 48 | +}) { | |
| 49 | + const regenerate = useStore((s) => s.regenerate) | |
| 50 | + const editAndResend = useStore((s) => s.editAndResend) | |
| 51 | + const deleteMessage = useStore((s) => s.deleteMessage) | |
| 52 | + const branchFrom = useStore((s) => s.branchFrom) | |
| 53 | + const setActiveVariant = useStore((s) => s.setActiveVariant) | |
| 54 | + const toast = useStore((s) => s.toast) | |
| 55 | + const [reasoningOpen, setReasoningOpen] = useState(false) | |
| 56 | + const [editing, setEditing] = useState(false) | |
| 57 | + const [draft, setDraft] = useState(message.text) | |
| 58 | + const [copied, setCopied] = useState(false) | |
| 59 | + | |
| 60 | + const isUser = message.role === 'user' | |
| 61 | + const variants = message.variants ?? [] | |
| 62 | + const activeVariant = | |
| 63 | + message.activeVariant !== undefined ? variants[message.activeVariant] : undefined | |
| 64 | + const shownText = activeVariant?.text ?? message.text | |
| 65 | + const shownReasoning = activeVariant?.reasoning ?? message.reasoning | |
| 66 | + const shownCitations = activeVariant?.citations ?? message.citations | |
| 67 | + const shownUsage = activeVariant?.usage ?? message.usage | |
| 68 | + const shownCost = activeVariant?.estimatedCost ?? message.estimatedCost | |
| 69 | + | |
| 70 | + const copy = () => { | |
| 71 | + void navigator.clipboard.writeText(shownText) | |
| 72 | + setCopied(true) | |
| 73 | + setTimeout(() => setCopied(false), 1200) | |
| 74 | + } | |
| 75 | + | |
| 76 | + const readAloud = () => { | |
| 77 | + if (!('speechSynthesis' in window)) { | |
| 78 | + toast('Speech synthesis not available in this browser', 'error') | |
| 79 | + return | |
| 80 | + } | |
| 81 | + speechSynthesis.cancel() | |
| 82 | + speechSynthesis.speak(new SpeechSynthesisUtterance(shownText)) | |
| 83 | + } | |
| 84 | + | |
| 85 | + return ( | |
| 86 | + <div className={`turn ${isUser ? 'user' : 'assistant'}`}> | |
| 87 | + {!isUser && ( | |
| 88 | + <div className="avatar"> | |
| 89 | + <ProviderGlyph provider={message.provider ?? 'custom'} size={12} /> | |
| 90 | + </div> | |
| 91 | + )} | |
| 92 | + <div className="turn-body"> | |
| 93 | + <div className="bubble"> | |
| 94 | + {(message.attachments ?? []).filter((a) => a.kind === 'image').length > 0 && ( | |
| 95 | + <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}> | |
| 96 | + {(message.attachments ?? []) | |
| 97 | + .filter((a) => a.kind === 'image') | |
| 98 | + .map((a) => ( | |
| 99 | + <img | |
| 100 | + key={a.id} | |
| 101 | + src={`data:${a.mimeType};base64,${a.data}`} | |
| 102 | + alt={a.fileName} | |
| 103 | + style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, objectFit: 'cover' }} | |
| 104 | + /> | |
| 105 | + ))} | |
| 106 | + </div> | |
| 107 | + )} | |
| 108 | + {(message.attachments ?? []).filter((a) => a.kind === 'textFile').length > 0 && ( | |
| 109 | + <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}> | |
| 110 | + {(message.attachments ?? []) | |
| 111 | + .filter((a) => a.kind === 'textFile') | |
| 112 | + .map((a) => ( | |
| 113 | + <span key={a.id} className="badge"> | |
| 114 | + {a.fileName} | |
| 115 | + </span> | |
| 116 | + ))} | |
| 117 | + </div> | |
| 118 | + )} | |
| 119 | + | |
| 120 | + {shownReasoning !== undefined && shownReasoning !== '' && ( | |
| 121 | + <div className="reasoning-section"> | |
| 122 | + <button className="reasoning-toggle" onClick={() => setReasoningOpen((o) => !o)}> | |
| 123 | + <span className={`chev${reasoningOpen ? ' open' : ''}`}> | |
| 124 | + <IconChevronRight /> | |
| 125 | + </span> | |
| 126 | + {streaming && shownText === '' ? 'Thinking…' : 'Thought process'} | |
| 127 | + </button> | |
| 128 | + {reasoningOpen && <div className="reasoning-body">{shownReasoning}</div>} | |
| 129 | + </div> | |
| 130 | + )} | |
| 131 | + | |
| 132 | + {editing ? ( | |
| 133 | + <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> | |
| 134 | + <textarea | |
| 135 | + className="text-input" | |
| 136 | + rows={Math.min(8, Math.max(2, draft.split('\n').length))} | |
| 137 | + value={draft} | |
| 138 | + onChange={(e) => setDraft(e.target.value)} | |
| 139 | + autoFocus | |
| 140 | + /> | |
| 141 | + <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> | |
| 142 | + <button className="button" onClick={() => setEditing(false)}> | |
| 143 | + Cancel | |
| 144 | + </button> | |
| 145 | + <button | |
| 146 | + className="button primary" | |
| 147 | + disabled={draft.trim() === ''} | |
| 148 | + onClick={() => { | |
| 149 | + setEditing(false) | |
| 150 | + void editAndResend(conversationID, message.id, draft) | |
| 151 | + }} | |
| 152 | + > | |
| 153 | + Resend | |
| 154 | + </button> | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + ) : isUser ? ( | |
| 158 | + shownText | |
| 159 | + ) : ( | |
| 160 | + <> | |
| 161 | + <Markdown text={shownText} /> | |
| 162 | + {streaming && <span className="streaming-caret" />} | |
| 163 | + </> | |
| 164 | + )} | |
| 165 | + | |
| 166 | + {shownCitations && shownCitations.length > 0 && ( | |
| 167 | + <div className="citations"> | |
| 168 | + {shownCitations.map((citation) => ( | |
| 169 | + <a | |
| 170 | + key={citation.index} | |
| 171 | + className="citation-chip" | |
| 172 | + href={citation.url} | |
| 173 | + target="_blank" | |
| 174 | + rel="noreferrer noopener" | |
| 175 | + title={citation.url} | |
| 176 | + > | |
| 177 | + <span className="num">{citation.index}</span> | |
| 178 | + <span className="title"> | |
| 179 | + {citation.title ?? new URL(citation.url).hostname} | |
| 180 | + </span> | |
| 181 | + </a> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 184 | + )} | |
| 185 | + | |
| 186 | + {message.errorText && ( | |
| 187 | + <div className="error-note"> | |
| 188 | + <span>⚠</span> | |
| 189 | + <span>{message.errorText}</span> | |
| 190 | + </div> | |
| 191 | + )} | |
| 192 | + </div> | |
| 193 | + | |
| 194 | + <div className={`meta-row${streaming ? ' always' : ''}`}> | |
| 195 | + {!isUser && variants.length > 0 && ( | |
| 196 | + <span className="variant-switch"> | |
| 197 | + <button | |
| 198 | + className="meta-action" | |
| 199 | + title="Previous variant" | |
| 200 | + onClick={() => { | |
| 201 | + const current = message.activeVariant ?? -1 | |
| 202 | + setActiveVariant(conversationID, message.id, Math.max(-1, current - 1) as number) | |
| 203 | + }} | |
| 204 | + > | |
| 205 | + ‹ | |
| 206 | + </button> | |
| 207 | + {(message.activeVariant ?? -1) + 2}/{variants.length + 1} | |
| 208 | + <button | |
| 209 | + className="meta-action" | |
| 210 | + title="Next variant" | |
| 211 | + onClick={() => { | |
| 212 | + const current = message.activeVariant ?? -1 | |
| 213 | + setActiveVariant( | |
| 214 | + conversationID, | |
| 215 | + message.id, | |
| 216 | + Math.min(variants.length - 1, current + 1) | |
| 217 | + ) | |
| 218 | + }} | |
| 219 | + > | |
| 220 | + › | |
| 221 | + </button> | |
| 222 | + </span> | |
| 223 | + )} | |
| 224 | + {!isUser && ( | |
| 225 | + <> | |
| 226 | + <button className="meta-action" title="Copy" onClick={copy}> | |
| 227 | + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />} | |
| 228 | + </button> | |
| 229 | + <button | |
| 230 | + className="meta-action" | |
| 231 | + title="Regenerate" | |
| 232 | + onClick={() => void regenerate(conversationID, message.id)} | |
| 233 | + > | |
| 234 | + <IconRefresh size={10} /> | |
| 235 | + </button> | |
| 236 | + <button className="meta-action" title="Quote reply" onClick={() => onQuote(shownText)}> | |
| 237 | + <IconQuote size={10} /> | |
| 238 | + </button> | |
| 239 | + <button | |
| 240 | + className="meta-action" | |
| 241 | + title="Branch from here" | |
| 242 | + onClick={() => branchFrom(conversationID, message.id)} | |
| 243 | + > | |
| 244 | + <IconBranch size={10} /> | |
| 245 | + </button> | |
| 246 | + <button className="meta-action" title="Read aloud" onClick={readAloud}> | |
| 247 | + <IconSpeaker size={10} /> | |
| 248 | + </button> | |
| 249 | + </> | |
| 250 | + )} | |
| 251 | + {isUser && ( | |
| 252 | + <> | |
| 253 | + <button className="meta-action" title="Copy" onClick={copy}> | |
| 254 | + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />} | |
| 255 | + </button> | |
| 256 | + <button | |
| 257 | + className="meta-action" | |
| 258 | + title="Edit & resend" | |
| 259 | + onClick={() => { | |
| 260 | + setDraft(message.text) | |
| 261 | + setEditing(true) | |
| 262 | + }} | |
| 263 | + > | |
| 264 | + <IconEdit size={10} /> | |
| 265 | + </button> | |
| 266 | + </> | |
| 267 | + )} | |
| 268 | + <button | |
| 269 | + className="meta-action" | |
| 270 | + title="Delete" | |
| 271 | + onClick={() => deleteMessage(conversationID, message.id)} | |
| 272 | + > | |
| 273 | + <IconTrash size={10} /> | |
| 274 | + </button> | |
| 275 | + <span>{timeLabel(message.createdAt)}</span> | |
| 276 | + {shownUsage && ( | |
| 277 | + <span> | |
| 278 | + {shownUsage.inputTokens}→{shownUsage.outputTokens} tok | |
| 279 | + </span> | |
| 280 | + )} | |
| 281 | + {shownCost !== undefined && shownCost > 0 && <span>{costLabel(shownCost)}</span>} | |
| 282 | + {streaming && <StopHint conversationID={conversationID} />} | |
| 283 | + </div> | |
| 284 | + </div> | |
| 285 | + </div> | |
| 286 | + ) | |
| 287 | +}) | |
| 288 | + | |
| 289 | +function StopHint({ conversationID }: { conversationID: string }) { | |
| 290 | + const stop = useStore((s) => s.stop) | |
| 291 | + const streaming = useStore((s) => s.streaming[conversationID]) | |
| 292 | + if (!streaming) return null | |
| 293 | + const elapsed = (Date.now() - streaming.startedAt) / 1000 | |
| 294 | + const tps = elapsed > 0 ? Math.round(streaming.outputChars / 4 / elapsed) : 0 | |
| 295 | + return ( | |
| 296 | + <> | |
| 297 | + <span> | |
| 298 | + {elapsed.toFixed(0)}s{tps > 0 ? ` · ~${tps} tok/s` : ''} | |
| 299 | + </span> | |
| 300 | + <button className="meta-action" onClick={() => stop(conversationID)}> | |
| 301 | + <IconX size={9} /> stop | |
| 302 | + </button> | |
| 303 | + </> | |
| 304 | + ) | |
| 305 | +} | |
added
src/components/ModelMenu.tsx
+331 −0
@@ -0,0 +1,331 @@ | ||
| 1 | +/* | |
| 2 | + * ModelMenu.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * The Model Menu — command-palette-style picker (also opened with ⌘K via the | |
| 9 | + * command palette's model section). Instant fuzzy search, capability filter | |
| 10 | + * chips, Favorites + Recents groups, provider sections (keyed providers | |
| 11 | + * first), capability/context/price badges, per-row star, "not configured" | |
| 12 | + * affordance, keyboard navigation. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +import { useEffect, useMemo, useRef, useState } from 'react' | |
| 16 | +import { CATALOG, priceBadge } from '../features/catalogHelpers' | |
| 17 | +import { PROVIDER_META } from '../providers/registry' | |
| 18 | +import { useStore } from '../state/store' | |
| 19 | +import { contextBadge, type AIModel, type Provider } from '../types' | |
| 20 | +import { | |
| 21 | + IconBrain, | |
| 22 | + IconEye, | |
| 23 | + IconSearch, | |
| 24 | + IconStar, | |
| 25 | + IconWrench, | |
| 26 | + ProviderGlyph, | |
| 27 | +} from './icons' | |
| 28 | + | |
| 29 | +const FILTERS = [ | |
| 30 | + { key: 'vision', label: 'Vision' }, | |
| 31 | + { key: 'tools', label: 'Tools' }, | |
| 32 | + { key: 'reasoning', label: 'Reasoning' }, | |
| 33 | + { key: 'jsonMode', label: 'JSON' }, | |
| 34 | + { key: 'long', label: 'Long context' }, | |
| 35 | + { key: 'cheap', label: 'Cheapest' }, | |
| 36 | +] as const | |
| 37 | + | |
| 38 | +type FilterKey = (typeof FILTERS)[number]['key'] | |
| 39 | + | |
| 40 | +/** Simple subsequence fuzzy match; returns a score (lower = better) or null. */ | |
| 41 | +function fuzzyScore(query: string, target: string): number | null { | |
| 42 | + const q = query.toLowerCase() | |
| 43 | + const t = target.toLowerCase() | |
| 44 | + if (q === '') return 0 | |
| 45 | + const direct = t.indexOf(q) | |
| 46 | + if (direct !== -1) return direct | |
| 47 | + let qi = 0 | |
| 48 | + let gaps = 0 | |
| 49 | + let last = -1 | |
| 50 | + for (let ti = 0; ti < t.length && qi < q.length; ti++) { | |
| 51 | + if (t[ti] === q[qi]) { | |
| 52 | + if (last !== -1) gaps += ti - last - 1 | |
| 53 | + last = ti | |
| 54 | + qi++ | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return qi === q.length ? 100 + gaps : null | |
| 58 | +} | |
| 59 | + | |
| 60 | +interface Row { | |
| 61 | + model: AIModel | |
| 62 | + section: string | |
| 63 | +} | |
| 64 | + | |
| 65 | +export default function ModelMenu({ | |
| 66 | + onPick, | |
| 67 | + onClose, | |
| 68 | + currentID, | |
| 69 | + title = 'Switch model', | |
| 70 | +}: { | |
| 71 | + onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void | |
| 72 | + onClose: () => void | |
| 73 | + currentID?: string | |
| 74 | + title?: string | |
| 75 | +}) { | |
| 76 | + const settings = useStore((s) => s.settings) | |
| 77 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 78 | + const keyedProviders = useStore((s) => s.keyedProviders) | |
| 79 | + const openSettings = useStore((s) => s.openSettings) | |
| 80 | + const [query, setQuery] = useState('') | |
| 81 | + const [filters, setFilters] = useState<Set<FilterKey>>(new Set()) | |
| 82 | + const [highlight, setHighlight] = useState(0) | |
| 83 | + const inputRef = useRef<HTMLInputElement>(null) | |
| 84 | + const listRef = useRef<HTMLDivElement>(null) | |
| 85 | + | |
| 86 | + useEffect(() => inputRef.current?.focus(), []) | |
| 87 | + | |
| 88 | + const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs]) | |
| 89 | + | |
| 90 | + const rows: Row[] = useMemo(() => { | |
| 91 | + let models = [...CATALOG] | |
| 92 | + // Capability filter chips | |
| 93 | + if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision) | |
| 94 | + if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools) | |
| 95 | + if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning) | |
| 96 | + if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode) | |
| 97 | + if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000) | |
| 98 | + if (filters.has('cheap')) { | |
| 99 | + models = models | |
| 100 | + .filter((m) => m.pricing) | |
| 101 | + .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0)) | |
| 102 | + .slice(0, 20) | |
| 103 | + } | |
| 104 | + // Fuzzy search over name + id + provider | |
| 105 | + if (query !== '') { | |
| 106 | + const scored = models | |
| 107 | + .map((m) => { | |
| 108 | + const scores = [ | |
| 109 | + fuzzyScore(query, m.displayName), | |
| 110 | + fuzzyScore(query, m.id), | |
| 111 | + fuzzyScore(query, PROVIDER_META[m.provider].displayName), | |
| 112 | + ].filter((s): s is number => s !== null) | |
| 113 | + return scores.length > 0 ? { m, score: Math.min(...scores) } : null | |
| 114 | + }) | |
| 115 | + .filter((x): x is { m: AIModel; score: number } => x !== null) | |
| 116 | + .sort((a, b) => a.score - b.score) | |
| 117 | + return scored.map(({ m }) => ({ model: m, section: 'Results' })) | |
| 118 | + } | |
| 119 | + // Grouped: aliases → favorites → recents → providers (keyed first) | |
| 120 | + const out: Row[] = [] | |
| 121 | + for (const [alias, ref] of Object.entries(settings.aliases)) { | |
| 122 | + const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID) | |
| 123 | + if (model) out.push({ model, section: `Alias: ${alias}` }) | |
| 124 | + } | |
| 125 | + const favorites = models.filter((m) => favoriteSet.has(m.id)) | |
| 126 | + for (const model of favorites) out.push({ model, section: 'Favorites' }) | |
| 127 | + const recents = settings.recentModelIDs | |
| 128 | + .map((id) => models.find((m) => m.id === id)) | |
| 129 | + .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id)) | |
| 130 | + .slice(0, 5) | |
| 131 | + for (const model of recents) out.push({ model, section: 'Recents' }) | |
| 132 | + const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => { | |
| 133 | + const ak = keyedProviders.includes(a) ? 0 : 1 | |
| 134 | + const bk = keyedProviders.includes(b) ? 0 : 1 | |
| 135 | + if (ak !== bk) return ak - bk | |
| 136 | + return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName) | |
| 137 | + }) | |
| 138 | + for (const provider of providerOrder) { | |
| 139 | + const list = models | |
| 140 | + .filter((m) => m.provider === provider) | |
| 141 | + .sort((a, b) => rankOf(a) - rankOf(b)) | |
| 142 | + for (const model of list) { | |
| 143 | + out.push({ model, section: PROVIDER_META[provider].displayName }) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + return out | |
| 147 | + | |
| 148 | + function rankOf(m: AIModel): number { | |
| 149 | + if (favoriteSet.has(m.id)) return 0 | |
| 150 | + if (m.isRecommended) return 1 | |
| 151 | + if (m.isLegacy) return 3 | |
| 152 | + return 2 | |
| 153 | + } | |
| 154 | + }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders]) | |
| 155 | + | |
| 156 | + useEffect(() => setHighlight(0), [query, filters]) | |
| 157 | + | |
| 158 | + const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => { | |
| 159 | + updateSettings({ | |
| 160 | + recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice( | |
| 161 | + 0, | |
| 162 | + 8 | |
| 163 | + ), | |
| 164 | + }) | |
| 165 | + onPick(model, scope) | |
| 166 | + onClose() | |
| 167 | + } | |
| 168 | + | |
| 169 | + const onKeyDown = (e: React.KeyboardEvent) => { | |
| 170 | + if (e.key === 'ArrowDown') { | |
| 171 | + e.preventDefault() | |
| 172 | + setHighlight((h) => Math.min(h + 1, rows.length - 1)) | |
| 173 | + scrollToHighlight(1) | |
| 174 | + } else if (e.key === 'ArrowUp') { | |
| 175 | + e.preventDefault() | |
| 176 | + setHighlight((h) => Math.max(h - 1, 0)) | |
| 177 | + scrollToHighlight(-1) | |
| 178 | + } else if (e.key === 'Enter') { | |
| 179 | + e.preventDefault() | |
| 180 | + const row = rows[highlight] | |
| 181 | + if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation') | |
| 182 | + } else if (e.key === 'Escape') { | |
| 183 | + onClose() | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + const scrollToHighlight = (dir: number) => { | |
| 188 | + requestAnimationFrame(() => { | |
| 189 | + const el = listRef.current?.querySelector('.model-row.highlighted') | |
| 190 | + el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' }) | |
| 191 | + }) | |
| 192 | + } | |
| 193 | + | |
| 194 | + const toggleFavorite = (e: React.MouseEvent, model: AIModel) => { | |
| 195 | + e.stopPropagation() | |
| 196 | + const next = favoriteSet.has(model.id) | |
| 197 | + ? settings.favoriteModelIDs.filter((id) => id !== model.id) | |
| 198 | + : [...settings.favoriteModelIDs, model.id] | |
| 199 | + updateSettings({ favoriteModelIDs: next }) | |
| 200 | + } | |
| 201 | + | |
| 202 | + let lastSection = '' | |
| 203 | + let rowIndex = -1 | |
| 204 | + | |
| 205 | + return ( | |
| 206 | + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}> | |
| 207 | + <div className="panel" role="dialog" aria-label={title} onKeyDown={onKeyDown}> | |
| 208 | + <div className="panel-search"> | |
| 209 | + <IconSearch size={14} /> | |
| 210 | + <input | |
| 211 | + ref={inputRef} | |
| 212 | + placeholder="Search models, providers, capabilities…" | |
| 213 | + value={query} | |
| 214 | + onChange={(e) => setQuery(e.target.value)} | |
| 215 | + /> | |
| 216 | + </div> | |
| 217 | + <div className="panel-filters"> | |
| 218 | + {FILTERS.map((f) => ( | |
| 219 | + <button | |
| 220 | + key={f.key} | |
| 221 | + className={`filter-chip${filters.has(f.key) ? ' active' : ''}`} | |
| 222 | + onClick={() => | |
| 223 | + setFilters((prev) => { | |
| 224 | + const next = new Set(prev) | |
| 225 | + if (next.has(f.key)) next.delete(f.key) | |
| 226 | + else next.add(f.key) | |
| 227 | + return next | |
| 228 | + }) | |
| 229 | + } | |
| 230 | + > | |
| 231 | + {f.label} | |
| 232 | + </button> | |
| 233 | + ))} | |
| 234 | + </div> | |
| 235 | + <div className="panel-list" ref={listRef}> | |
| 236 | + {rows.length === 0 && <div className="panel-empty">No models match.</div>} | |
| 237 | + {rows.map((row) => { | |
| 238 | + rowIndex++ | |
| 239 | + const index = rowIndex | |
| 240 | + const header = | |
| 241 | + row.section !== lastSection ? ( | |
| 242 | + <div className="panel-section-header" key={`h-${row.section}`}> | |
| 243 | + {row.section} | |
| 244 | + </div> | |
| 245 | + ) : null | |
| 246 | + lastSection = row.section | |
| 247 | + const model = row.model | |
| 248 | + const keyed = keyedProviders.includes(model.provider) | |
| 249 | + return ( | |
| 250 | + <div key={`${row.section}-${model.provider}-${model.id}`}> | |
| 251 | + {header} | |
| 252 | + <button | |
| 253 | + className={`model-row${index === highlight ? ' highlighted' : ''}${ | |
| 254 | + model.id === currentID ? ' current' : '' | |
| 255 | + }`} | |
| 256 | + onClick={(e) => | |
| 257 | + pick(model, e.metaKey || e.ctrlKey ? 'default' : 'conversation') | |
| 258 | + } | |
| 259 | + onMouseMove={() => setHighlight(index)} | |
| 260 | + > | |
| 261 | + <span className="glyph"> | |
| 262 | + <ProviderGlyph provider={model.provider} /> | |
| 263 | + </span> | |
| 264 | + <span className="model-row-name">{model.displayName}</span> | |
| 265 | + <span className="model-row-provider"> | |
| 266 | + {PROVIDER_META[model.provider].displayName} | |
| 267 | + </span> | |
| 268 | + {model.isRecommended && <span className="badge accent">Featured</span>} | |
| 269 | + {model.isLegacy && <span className="badge">Legacy</span>} | |
| 270 | + {!keyed && ( | |
| 271 | + <button | |
| 272 | + className="badge warn" | |
| 273 | + title="No API key configured — add one" | |
| 274 | + onClick={(e) => { | |
| 275 | + e.stopPropagation() | |
| 276 | + onClose() | |
| 277 | + openSettings('providers') | |
| 278 | + }} | |
| 279 | + > | |
| 280 | + Add key | |
| 281 | + </button> | |
| 282 | + )} | |
| 283 | + <span className="model-row-badges"> | |
| 284 | + {model.capabilities.vision && ( | |
| 285 | + <span className="capability-badge" title="Vision"> | |
| 286 | + <IconEye /> | |
| 287 | + </span> | |
| 288 | + )} | |
| 289 | + {model.capabilities.reasoning && ( | |
| 290 | + <span className="capability-badge" title="Reasoning"> | |
| 291 | + <IconBrain /> | |
| 292 | + </span> | |
| 293 | + )} | |
| 294 | + {model.capabilities.tools && ( | |
| 295 | + <span className="capability-badge" title="Tools"> | |
| 296 | + <IconWrench /> | |
| 297 | + </span> | |
| 298 | + )} | |
| 299 | + <span>{contextBadge(model)}</span> | |
| 300 | + <span title="USD per 1M tokens in / out">{priceBadge(model)}</span> | |
| 301 | + <button | |
| 302 | + className={`star${favoriteSet.has(model.id) ? ' active' : ''}`} | |
| 303 | + title="Favorite" | |
| 304 | + onClick={(e) => toggleFavorite(e, model)} | |
| 305 | + > | |
| 306 | + <IconStar filled={favoriteSet.has(model.id)} /> | |
| 307 | + </button> | |
| 308 | + </span> | |
| 309 | + </button> | |
| 310 | + </div> | |
| 311 | + ) | |
| 312 | + })} | |
| 313 | + </div> | |
| 314 | + <div className="panel-footer"> | |
| 315 | + <span> | |
| 316 | + <kbd>↑↓</kbd> navigate | |
| 317 | + </span> | |
| 318 | + <span> | |
| 319 | + <kbd>↵</kbd> use for this chat | |
| 320 | + </span> | |
| 321 | + <span> | |
| 322 | + <kbd>⌘↵</kbd> set as default | |
| 323 | + </span> | |
| 324 | + <span> | |
| 325 | + <kbd>esc</kbd> close | |
| 326 | + </span> | |
| 327 | + </div> | |
| 328 | + </div> | |
| 329 | + </div> | |
| 330 | + ) | |
| 331 | +} | |
added
src/components/ParamsPanel.tsx
+147 −0
@@ -0,0 +1,147 @@ | ||
| 1 | +/* | |
| 2 | + * ParamsPanel.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Per-conversation parameter editor. Only shows controls the active model's | |
| 9 | + * ParameterSupport allows; every control is gated by an enable checkbox — | |
| 10 | + * unchecked means "provider default" and the key is omitted from requests | |
| 11 | + * entirely (native behavior). | |
| 12 | + */ | |
| 13 | + | |
| 14 | +import { findModel } from '../features/catalogHelpers' | |
| 15 | +import { useStore } from '../state/store' | |
| 16 | +import type { ChatParameters, Conversation } from '../types' | |
| 17 | + | |
| 18 | +export default function ParamsPanel({ | |
| 19 | + conversation, | |
| 20 | + onClose, | |
| 21 | +}: { | |
| 22 | + conversation: Conversation | |
| 23 | + onClose: () => void | |
| 24 | +}) { | |
| 25 | + const updateConversation = useStore((s) => s.updateConversation) | |
| 26 | + const model = findModel(conversation.provider, conversation.modelID) | |
| 27 | + const support = model?.parameterSupport | |
| 28 | + const params = conversation.parameters | |
| 29 | + | |
| 30 | + const patch = (p: Partial<ChatParameters>) => { | |
| 31 | + const merged = { ...params, ...p } | |
| 32 | + for (const key of Object.keys(merged) as (keyof ChatParameters)[]) { | |
| 33 | + if (merged[key] === undefined) delete merged[key] | |
| 34 | + } | |
| 35 | + updateConversation(conversation.id, { parameters: merged }) | |
| 36 | + } | |
| 37 | + | |
| 38 | + const slider = ( | |
| 39 | + label: string, | |
| 40 | + key: 'temperature' | 'topP' | 'frequencyPenalty' | 'presencePenalty', | |
| 41 | + min: number, | |
| 42 | + max: number, | |
| 43 | + step: number, | |
| 44 | + fallback: number | |
| 45 | + ) => ( | |
| 46 | + <div className="settings-row" key={key}> | |
| 47 | + <label style={{ display: 'flex', alignItems: 'center', gap: 6, width: 150 }}> | |
| 48 | + <input | |
| 49 | + type="checkbox" | |
| 50 | + checked={params[key] !== undefined} | |
| 51 | + onChange={(e) => patch({ [key]: e.target.checked ? fallback : undefined })} | |
| 52 | + /> | |
| 53 | + {label} | |
| 54 | + </label> | |
| 55 | + {params[key] !== undefined ? ( | |
| 56 | + <> | |
| 57 | + <input | |
| 58 | + type="range" | |
| 59 | + min={min} | |
| 60 | + max={max} | |
| 61 | + step={step} | |
| 62 | + value={params[key]} | |
| 63 | + style={{ flex: 1, accentColor: 'var(--z-accent)' }} | |
| 64 | + onChange={(e) => patch({ [key]: Number(e.target.value) })} | |
| 65 | + /> | |
| 66 | + <span style={{ width: 40, textAlign: 'right', fontSize: 12 }}> | |
| 67 | + {params[key]?.toFixed(2)} | |
| 68 | + </span> | |
| 69 | + </> | |
| 70 | + ) : ( | |
| 71 | + <span className="settings-note" style={{ marginLeft: 'auto' }}> | |
| 72 | + default | |
| 73 | + </span> | |
| 74 | + )} | |
| 75 | + </div> | |
| 76 | + ) | |
| 77 | + | |
| 78 | + return ( | |
| 79 | + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}> | |
| 80 | + <div className="panel" style={{ width: 420 }}> | |
| 81 | + <div className="settings-content"> | |
| 82 | + <h3>Parameters — {model?.displayName ?? conversation.modelID}</h3> | |
| 83 | + {support?.temperature && slider('Temperature', 'temperature', 0, 2, 0.05, 1)} | |
| 84 | + {support?.topP && slider('Top P', 'topP', 0, 1, 0.01, 1)} | |
| 85 | + {support?.frequencyPenalty && | |
| 86 | + slider('Frequency penalty', 'frequencyPenalty', -2, 2, 0.1, 0)} | |
| 87 | + {support?.presencePenalty && slider('Presence penalty', 'presencePenalty', -2, 2, 0.1, 0)} | |
| 88 | + <div className="settings-row"> | |
| 89 | + <span style={{ width: 150 }}>Max tokens</span> | |
| 90 | + <input | |
| 91 | + className="number-input" | |
| 92 | + type="number" | |
| 93 | + min={1} | |
| 94 | + placeholder="default" | |
| 95 | + style={{ width: 100 }} | |
| 96 | + value={params.maxTokens ?? ''} | |
| 97 | + onChange={(e) => | |
| 98 | + patch({ maxTokens: e.target.value === '' ? undefined : Number(e.target.value) }) | |
| 99 | + } | |
| 100 | + /> | |
| 101 | + </div> | |
| 102 | + {support?.reasoningEffort && ( | |
| 103 | + <div className="settings-row"> | |
| 104 | + <span style={{ width: 150 }}>Reasoning effort</span> | |
| 105 | + <select | |
| 106 | + className="select" | |
| 107 | + value={params.reasoningEffort ?? ''} | |
| 108 | + onChange={(e) => | |
| 109 | + patch({ | |
| 110 | + reasoningEffort: | |
| 111 | + e.target.value === '' | |
| 112 | + ? undefined | |
| 113 | + : (e.target.value as 'low' | 'medium' | 'high'), | |
| 114 | + }) | |
| 115 | + } | |
| 116 | + > | |
| 117 | + <option value="">Default</option> | |
| 118 | + <option value="low">Low</option> | |
| 119 | + <option value="medium">Medium</option> | |
| 120 | + <option value="high">High</option> | |
| 121 | + </select> | |
| 122 | + </div> | |
| 123 | + )} | |
| 124 | + {support?.thinkingToggle && ( | |
| 125 | + <div className="settings-row"> | |
| 126 | + <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
| 127 | + <input | |
| 128 | + type="checkbox" | |
| 129 | + checked={params.thinkingEnabled ?? false} | |
| 130 | + onChange={(e) => | |
| 131 | + patch({ thinkingEnabled: e.target.checked ? true : undefined }) | |
| 132 | + } | |
| 133 | + /> | |
| 134 | + Extended thinking | |
| 135 | + </label> | |
| 136 | + </div> | |
| 137 | + )} | |
| 138 | + <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}> | |
| 139 | + <button className="button" onClick={onClose}> | |
| 140 | + Done | |
| 141 | + </button> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + ) | |
| 147 | +} | |
added
src/components/SettingsModal.tsx
+596 −0
@@ -0,0 +1,596 @@ | ||
| 1 | +/* | |
| 2 | + * SettingsModal.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Settings: Providers & Keys (masked fields, Test with status dot + latency, | |
| 9 | + * proxy base URL, honesty notice), Models (catalog browser, favorites, | |
| 10 | + * default), Appearance (light default / dark / system, accents, font size), | |
| 11 | + * Data (export/import/clear), Advanced (passphrase lock, default system | |
| 12 | + * prompt, streaming toggle). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +import { useMemo, useRef, useState } from 'react' | |
| 16 | +import { CATALOG, priceBadge } from '../features/catalogHelpers' | |
| 17 | +import { downloadBackup } from '../features/exporters' | |
| 18 | +import { testKey } from '../providers/testKey' | |
| 19 | +import { PROVIDER_META } from '../providers/registry' | |
| 20 | +import { useStore } from '../state/store' | |
| 21 | +import { importAll, isExportedData, clearEverything } from '../storage/backup' | |
| 22 | +import { clearConversations } from '../storage/conversations' | |
| 23 | +import { getKey, maskedKey, removeKey, setKey } from '../storage/keys' | |
| 24 | +import { disableVault, enableVault, isVaultEnabled, persistVault } from '../storage/vault' | |
| 25 | +import { contextBadge, BUILT_IN_PROVIDERS, type Provider } from '../types' | |
| 26 | +import { | |
| 27 | + IconBrain, | |
| 28 | + IconEye, | |
| 29 | + IconStar, | |
| 30 | + IconWrench, | |
| 31 | + IconX, | |
| 32 | + ProviderGlyph, | |
| 33 | +} from './icons' | |
| 34 | + | |
| 35 | +const TABS = [ | |
| 36 | + { id: 'providers', label: 'Providers & Keys' }, | |
| 37 | + { id: 'models', label: 'Models' }, | |
| 38 | + { id: 'appearance', label: 'Appearance' }, | |
| 39 | + { id: 'data', label: 'Data' }, | |
| 40 | + { id: 'advanced', label: 'Advanced' }, | |
| 41 | +] | |
| 42 | + | |
| 43 | +type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string } | |
| 44 | + | |
| 45 | +export default function SettingsModal() { | |
| 46 | + const settingsTab = useStore((s) => s.settingsTab) | |
| 47 | + const closeSettings = useStore((s) => s.closeSettings) | |
| 48 | + const [tab, setTab] = useState(settingsTab) | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && closeSettings()}> | |
| 52 | + <div className="panel settings-panel" role="dialog" aria-label="Settings"> | |
| 53 | + <div className="panel-search" style={{ justifyContent: 'space-between' }}> | |
| 54 | + <strong style={{ fontSize: 14 }}>Settings</strong> | |
| 55 | + <button className="icon-button" onClick={closeSettings}> | |
| 56 | + <IconX /> | |
| 57 | + </button> | |
| 58 | + </div> | |
| 59 | + <div className="settings-body"> | |
| 60 | + <nav className="settings-tabs"> | |
| 61 | + {TABS.map((t) => ( | |
| 62 | + <button | |
| 63 | + key={t.id} | |
| 64 | + className={`settings-tab${tab === t.id ? ' active' : ''}`} | |
| 65 | + onClick={() => setTab(t.id)} | |
| 66 | + > | |
| 67 | + {t.label} | |
| 68 | + </button> | |
| 69 | + ))} | |
| 70 | + </nav> | |
| 71 | + <div className="settings-content"> | |
| 72 | + {tab === 'providers' && <ProvidersTab />} | |
| 73 | + {tab === 'models' && <ModelsTab />} | |
| 74 | + {tab === 'appearance' && <AppearanceTab />} | |
| 75 | + {tab === 'data' && <DataTab />} | |
| 76 | + {tab === 'advanced' && <AdvancedTab />} | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + ) | |
| 82 | +} | |
| 83 | + | |
| 84 | +function ProvidersTab() { | |
| 85 | + const refreshKeyedProviders = useStore((s) => s.refreshKeyedProviders) | |
| 86 | + const settings = useStore((s) => s.settings) | |
| 87 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 88 | + const [drafts, setDrafts] = useState<Partial<Record<Provider, string>>>({}) | |
| 89 | + const [tests, setTests] = useState<Partial<Record<Provider, TestState>>>({}) | |
| 90 | + const [proxyFor, setProxyFor] = useState<Provider | null>(null) | |
| 91 | + const [, forceRender] = useState(0) | |
| 92 | + | |
| 93 | + const saveDraft = (provider: Provider) => { | |
| 94 | + const draft = drafts[provider] | |
| 95 | + if (draft !== undefined && draft !== '') { | |
| 96 | + setKey(provider, draft.trim()) | |
| 97 | + setDrafts((d) => ({ ...d, [provider]: undefined })) | |
| 98 | + refreshKeyedProviders() | |
| 99 | + forceRender((n) => n + 1) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + const runTest = async (provider: Provider) => { | |
| 104 | + saveDraft(provider) | |
| 105 | + const key = getKey(provider) | |
| 106 | + if (!key) { | |
| 107 | + setTests((t) => ({ ...t, [provider]: { status: 'fail', detail: 'No key' } })) | |
| 108 | + return | |
| 109 | + } | |
| 110 | + setTests((t) => ({ ...t, [provider]: { status: 'testing' } })) | |
| 111 | + try { | |
| 112 | + const ms = await testKey(provider, key, settings.proxyBaseURLs[provider]) | |
| 113 | + setTests((t) => ({ | |
| 114 | + ...t, | |
| 115 | + [provider]: { status: 'ok', detail: `Verified · ${Math.round(ms)} ms` }, | |
| 116 | + })) | |
| 117 | + } catch (err) { | |
| 118 | + setTests((t) => ({ | |
| 119 | + ...t, | |
| 120 | + [provider]: { status: 'fail', detail: err instanceof Error ? err.message : String(err) }, | |
| 121 | + })) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <div> | |
| 127 | + <div className="first-run-note" style={{ marginBottom: 16 }}> | |
| 128 | + <span> | |
| 129 | + <strong>Your keys stay in this browser.</strong> They're stored in localStorage on this | |
| 130 | + device and sent only to the provider you call. Anyone with access to this browser | |
| 131 | + profile (or any script that runs on this page) could read them — that's inherent to a | |
| 132 | + backend-less app. Use the optional passphrase lock in Advanced for at-rest encryption. | |
| 133 | + </span> | |
| 134 | + </div> | |
| 135 | + {BUILT_IN_PROVIDERS.map((provider) => { | |
| 136 | + const saved = getKey(provider) | |
| 137 | + const test = tests[provider] ?? { status: 'idle' } | |
| 138 | + const proxy = settings.proxyBaseURLs[provider] | |
| 139 | + return ( | |
| 140 | + <div className="settings-row" key={provider}> | |
| 141 | + <span className="provider-name"> | |
| 142 | + <span style={{ color: 'var(--z-accent)', display: 'inline-flex' }}> | |
| 143 | + <ProviderGlyph provider={provider} size={13} /> | |
| 144 | + </span> | |
| 145 | + {PROVIDER_META[provider].displayName} | |
| 146 | + <span | |
| 147 | + className={`status-dot ${ | |
| 148 | + test.status === 'ok' | |
| 149 | + ? 'ok' | |
| 150 | + : test.status === 'fail' | |
| 151 | + ? 'fail' | |
| 152 | + : saved | |
| 153 | + ? 'saved' | |
| 154 | + : '' | |
| 155 | + }`} | |
| 156 | + /> | |
| 157 | + </span> | |
| 158 | + <input | |
| 159 | + className="key-input" | |
| 160 | + type="password" | |
| 161 | + placeholder={saved ? maskedKey(saved) : 'API key'} | |
| 162 | + value={drafts[provider] ?? ''} | |
| 163 | + onChange={(e) => setDrafts((d) => ({ ...d, [provider]: e.target.value }))} | |
| 164 | + onBlur={() => saveDraft(provider)} | |
| 165 | + /> | |
| 166 | + <button className="button" onClick={() => void runTest(provider)}> | |
| 167 | + {test.status === 'testing' ? 'Testing…' : 'Test'} | |
| 168 | + </button> | |
| 169 | + <button | |
| 170 | + className="button" | |
| 171 | + title="Proxy / base URL override" | |
| 172 | + onClick={() => setProxyFor(proxyFor === provider ? null : provider)} | |
| 173 | + > | |
| 174 | + Proxy{proxy ? ' •' : ''} | |
| 175 | + </button> | |
| 176 | + {saved && ( | |
| 177 | + <button | |
| 178 | + className="icon-button danger" | |
| 179 | + title="Remove key" | |
| 180 | + onClick={() => { | |
| 181 | + removeKey(provider) | |
| 182 | + refreshKeyedProviders() | |
| 183 | + setTests((t) => ({ ...t, [provider]: { status: 'idle' } })) | |
| 184 | + forceRender((n) => n + 1) | |
| 185 | + }} | |
| 186 | + > | |
| 187 | + <IconX size={10} /> | |
| 188 | + </button> | |
| 189 | + )} | |
| 190 | + {test.detail && ( | |
| 191 | + <span className={`settings-sub ${test.status === 'ok' ? 'ok' : 'fail'}`}> | |
| 192 | + {test.detail} | |
| 193 | + </span> | |
| 194 | + )} | |
| 195 | + {proxyFor === provider && ( | |
| 196 | + <div style={{ width: '100%', display: 'flex', gap: 8, alignItems: 'center' }}> | |
| 197 | + <input | |
| 198 | + className="text-input" | |
| 199 | + style={{ flex: 1 }} | |
| 200 | + placeholder={`Proxy base URL (e.g. http://localhost:8787) — default: ${PROVIDER_META[provider].defaultBaseURL}`} | |
| 201 | + value={proxy ?? ''} | |
| 202 | + onChange={(e) => | |
| 203 | + updateSettings({ | |
| 204 | + proxyBaseURLs: { | |
| 205 | + ...settings.proxyBaseURLs, | |
| 206 | + [provider]: e.target.value === '' ? undefined : e.target.value, | |
| 207 | + }, | |
| 208 | + }) | |
| 209 | + } | |
| 210 | + /> | |
| 211 | + </div> | |
| 212 | + )} | |
| 213 | + </div> | |
| 214 | + ) | |
| 215 | + })} | |
| 216 | + <p className="settings-note" style={{ marginTop: 12 }}> | |
| 217 | + All 12 providers currently allow direct browser calls (verified — see docs/CORS-MATRIX.md). | |
| 218 | + If a provider starts blocking CORS, set a proxy URL above or run Zyquo Router locally and | |
| 219 | + point the override at it. | |
| 220 | + </p> | |
| 221 | + </div> | |
| 222 | + ) | |
| 223 | +} | |
| 224 | + | |
| 225 | +function ModelsTab() { | |
| 226 | + const settings = useStore((s) => s.settings) | |
| 227 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 228 | + const [provider, setProvider] = useState<Provider>('openai') | |
| 229 | + const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs]) | |
| 230 | + const models = useMemo(() => CATALOG.filter((m) => m.provider === provider), [provider]) | |
| 231 | + | |
| 232 | + return ( | |
| 233 | + <div> | |
| 234 | + <div className="settings-row" style={{ borderBottom: 'none' }}> | |
| 235 | + <select | |
| 236 | + className="select" | |
| 237 | + value={provider} | |
| 238 | + onChange={(e) => setProvider(e.target.value as Provider)} | |
| 239 | + > | |
| 240 | + {BUILT_IN_PROVIDERS.map((p) => ( | |
| 241 | + <option key={p} value={p}> | |
| 242 | + {PROVIDER_META[p].displayName} | |
| 243 | + </option> | |
| 244 | + ))} | |
| 245 | + </select> | |
| 246 | + <span className="settings-note">{models.length} models</span> | |
| 247 | + </div> | |
| 248 | + {models.map((model) => ( | |
| 249 | + <div className="settings-row" key={model.id}> | |
| 250 | + <button | |
| 251 | + className={`star${favoriteSet.has(model.id) ? ' active' : ''}`} | |
| 252 | + style={{ color: favoriteSet.has(model.id) ? 'var(--z-warning)' : 'var(--z-text-tertiary)' }} | |
| 253 | + onClick={() => | |
| 254 | + updateSettings({ | |
| 255 | + favoriteModelIDs: favoriteSet.has(model.id) | |
| 256 | + ? settings.favoriteModelIDs.filter((id) => id !== model.id) | |
| 257 | + : [...settings.favoriteModelIDs, model.id], | |
| 258 | + }) | |
| 259 | + } | |
| 260 | + > | |
| 261 | + <IconStar filled={favoriteSet.has(model.id)} /> | |
| 262 | + </button> | |
| 263 | + <div style={{ minWidth: 0, flex: 1 }}> | |
| 264 | + <div style={{ fontSize: 13, display: 'flex', gap: 6, alignItems: 'center' }}> | |
| 265 | + {model.displayName} | |
| 266 | + {model.isRecommended && <span className="badge accent">Featured</span>} | |
| 267 | + {model.isLegacy && <span className="badge">Legacy</span>} | |
| 268 | + {settings.defaultModelID === model.id && settings.defaultProvider === provider && ( | |
| 269 | + <span className="badge accent">Default</span> | |
| 270 | + )} | |
| 271 | + </div> | |
| 272 | + <div | |
| 273 | + style={{ | |
| 274 | + fontSize: 10, | |
| 275 | + fontFamily: 'var(--z-font-mono)', | |
| 276 | + color: 'var(--z-text-tertiary)', | |
| 277 | + overflow: 'hidden', | |
| 278 | + textOverflow: 'ellipsis', | |
| 279 | + whiteSpace: 'nowrap', | |
| 280 | + }} | |
| 281 | + > | |
| 282 | + {model.id} | |
| 283 | + </div> | |
| 284 | + </div> | |
| 285 | + <span className="model-row-badges"> | |
| 286 | + {model.capabilities.vision && <IconEye />} | |
| 287 | + {model.capabilities.reasoning && <IconBrain />} | |
| 288 | + {model.capabilities.tools && <IconWrench />} | |
| 289 | + <span>{contextBadge(model)}</span> | |
| 290 | + <span>{priceBadge(model)}</span> | |
| 291 | + </span> | |
| 292 | + <button | |
| 293 | + className="button" | |
| 294 | + onClick={() => updateSettings({ defaultModelID: model.id, defaultProvider: provider })} | |
| 295 | + > | |
| 296 | + Set default | |
| 297 | + </button> | |
| 298 | + </div> | |
| 299 | + ))} | |
| 300 | + </div> | |
| 301 | + ) | |
| 302 | +} | |
| 303 | + | |
| 304 | +function AppearanceTab() { | |
| 305 | + const settings = useStore((s) => s.settings) | |
| 306 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 307 | + return ( | |
| 308 | + <div> | |
| 309 | + <div className="settings-section"> | |
| 310 | + <h3>Theme</h3> | |
| 311 | + <div style={{ display: 'flex', gap: 8 }}> | |
| 312 | + {(['light', 'dark', 'system'] as const).map((theme) => ( | |
| 313 | + <button | |
| 314 | + key={theme} | |
| 315 | + className={`button${settings.theme === theme ? ' primary' : ''}`} | |
| 316 | + onClick={() => updateSettings({ theme })} | |
| 317 | + > | |
| 318 | + {theme[0]?.toUpperCase() + theme.slice(1)} | |
| 319 | + </button> | |
| 320 | + ))} | |
| 321 | + </div> | |
| 322 | + <p className="settings-note" style={{ marginTop: 6 }}> | |
| 323 | + Light is the Zyquo flagship theme and the default. | |
| 324 | + </p> | |
| 325 | + </div> | |
| 326 | + <div className="settings-section"> | |
| 327 | + <h3>Accent</h3> | |
| 328 | + <div style={{ display: 'flex', gap: 8 }}> | |
| 329 | + {( | |
| 330 | + [ | |
| 331 | + ['indigo', '#4E6AF0'], | |
| 332 | + ['graphite', '#5B6270'], | |
| 333 | + ['teal', '#1D9A8F'], | |
| 334 | + ['amber', '#C77D1D'], | |
| 335 | + ['rose', '#C94F7C'], | |
| 336 | + ] as const | |
| 337 | + ).map(([accent, color]) => ( | |
| 338 | + <button | |
| 339 | + key={accent} | |
| 340 | + title={accent} | |
| 341 | + onClick={() => updateSettings({ accent })} | |
| 342 | + style={{ | |
| 343 | + width: 28, | |
| 344 | + height: 28, | |
| 345 | + borderRadius: '50%', | |
| 346 | + background: color, | |
| 347 | + border: | |
| 348 | + settings.accent === accent | |
| 349 | + ? '2px solid var(--z-text-primary)' | |
| 350 | + : '2px solid transparent', | |
| 351 | + }} | |
| 352 | + /> | |
| 353 | + ))} | |
| 354 | + </div> | |
| 355 | + </div> | |
| 356 | + <div className="settings-section"> | |
| 357 | + <h3>Chat font size</h3> | |
| 358 | + <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> | |
| 359 | + <input | |
| 360 | + type="range" | |
| 361 | + min={12} | |
| 362 | + max={18} | |
| 363 | + step={0.5} | |
| 364 | + value={settings.chatFontSize} | |
| 365 | + style={{ flex: 1, accentColor: 'var(--z-accent)' }} | |
| 366 | + onChange={(e) => updateSettings({ chatFontSize: Number(e.target.value) })} | |
| 367 | + /> | |
| 368 | + <span style={{ fontSize: 12 }}>{settings.chatFontSize.toFixed(1)} px</span> | |
| 369 | + </div> | |
| 370 | + <div | |
| 371 | + style={{ | |
| 372 | + marginTop: 8, | |
| 373 | + padding: 12, | |
| 374 | + background: 'var(--z-surface)', | |
| 375 | + border: 'var(--z-hairline) solid var(--z-border)', | |
| 376 | + borderRadius: 10, | |
| 377 | + fontSize: settings.chatFontSize, | |
| 378 | + lineHeight: 1.45, | |
| 379 | + }} | |
| 380 | + > | |
| 381 | + The quick brown fox jumps over the lazy dog. | |
| 382 | + </div> | |
| 383 | + </div> | |
| 384 | + <div className="settings-section"> | |
| 385 | + <h3>Density</h3> | |
| 386 | + <div style={{ display: 'flex', gap: 8 }}> | |
| 387 | + {(['comfortable', 'compact'] as const).map((density) => ( | |
| 388 | + <button | |
| 389 | + key={density} | |
| 390 | + className={`button${settings.density === density ? ' primary' : ''}`} | |
| 391 | + onClick={() => updateSettings({ density })} | |
| 392 | + > | |
| 393 | + {density[0]?.toUpperCase() + density.slice(1)} | |
| 394 | + </button> | |
| 395 | + ))} | |
| 396 | + </div> | |
| 397 | + </div> | |
| 398 | + </div> | |
| 399 | + ) | |
| 400 | +} | |
| 401 | + | |
| 402 | +function DataTab() { | |
| 403 | + const toast = useStore((s) => s.toast) | |
| 404 | + const loadAll = useStore((s) => s.loadAll) | |
| 405 | + const fileRef = useRef<HTMLInputElement>(null) | |
| 406 | + const [includeKeys, setIncludeKeys] = useState(false) | |
| 407 | + const [confirmClear, setConfirmClear] = useState<string | null>(null) | |
| 408 | + | |
| 409 | + const doImport = async (file: File) => { | |
| 410 | + try { | |
| 411 | + const parsed: unknown = JSON.parse(await file.text()) | |
| 412 | + if (!isExportedData(parsed)) throw new Error('Not a Zyquo Cloud Web export file') | |
| 413 | + const result = await importAll(parsed) | |
| 414 | + await loadAll() | |
| 415 | + toast( | |
| 416 | + `Imported ${result.conversations} conversations${result.keys > 0 ? ` and ${result.keys} keys` : ''}`, | |
| 417 | + 'success' | |
| 418 | + ) | |
| 419 | + } catch (err) { | |
| 420 | + toast(err instanceof Error ? err.message : 'Import failed', 'error') | |
| 421 | + } | |
| 422 | + } | |
| 423 | + | |
| 424 | + return ( | |
| 425 | + <div> | |
| 426 | + <div className="settings-section"> | |
| 427 | + <h3>Export</h3> | |
| 428 | + <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}> | |
| 429 | + <input | |
| 430 | + type="checkbox" | |
| 431 | + checked={includeKeys} | |
| 432 | + onChange={(e) => setIncludeKeys(e.target.checked)} | |
| 433 | + /> | |
| 434 | + Include API keys | |
| 435 | + </label> | |
| 436 | + {includeKeys && ( | |
| 437 | + <p className="settings-note" style={{ color: 'var(--z-warning)' }}> | |
| 438 | + ⚠ The exported file will contain your API keys in plain text. Store it safely. | |
| 439 | + </p> | |
| 440 | + )} | |
| 441 | + <button | |
| 442 | + className="button primary" | |
| 443 | + style={{ marginTop: 8 }} | |
| 444 | + onClick={() => void downloadBackup(includeKeys)} | |
| 445 | + > | |
| 446 | + Download backup (JSON) | |
| 447 | + </button> | |
| 448 | + </div> | |
| 449 | + <div className="settings-section"> | |
| 450 | + <h3>Import</h3> | |
| 451 | + <input | |
| 452 | + ref={fileRef} | |
| 453 | + type="file" | |
| 454 | + accept="application/json" | |
| 455 | + hidden | |
| 456 | + onChange={(e) => { | |
| 457 | + const file = e.target.files?.[0] | |
| 458 | + if (file) void doImport(file) | |
| 459 | + e.target.value = '' | |
| 460 | + }} | |
| 461 | + /> | |
| 462 | + <button className="button" onClick={() => fileRef.current?.click()}> | |
| 463 | + Import backup file… | |
| 464 | + </button> | |
| 465 | + </div> | |
| 466 | + <div className="settings-section"> | |
| 467 | + <h3>Clear data</h3> | |
| 468 | + <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> | |
| 469 | + {( | |
| 470 | + [ | |
| 471 | + ['conversations', 'Delete all conversations'], | |
| 472 | + ['everything', 'Delete everything (keys, history, settings)'], | |
| 473 | + ] as const | |
| 474 | + ).map(([kind, label]) => ( | |
| 475 | + <button | |
| 476 | + key={kind} | |
| 477 | + className="button danger" | |
| 478 | + onClick={async () => { | |
| 479 | + if (confirmClear !== kind) { | |
| 480 | + setConfirmClear(kind) | |
| 481 | + setTimeout(() => setConfirmClear(null), 4000) | |
| 482 | + return | |
| 483 | + } | |
| 484 | + if (kind === 'conversations') await clearConversations() | |
| 485 | + else await clearEverything() | |
| 486 | + await loadAll() | |
| 487 | + setConfirmClear(null) | |
| 488 | + toast('Cleared', 'success') | |
| 489 | + if (kind === 'everything') window.location.reload() | |
| 490 | + }} | |
| 491 | + > | |
| 492 | + {confirmClear === kind ? 'Click again to confirm' : label} | |
| 493 | + </button> | |
| 494 | + ))} | |
| 495 | + </div> | |
| 496 | + </div> | |
| 497 | + </div> | |
| 498 | + ) | |
| 499 | +} | |
| 500 | + | |
| 501 | +function AdvancedTab() { | |
| 502 | + const settings = useStore((s) => s.settings) | |
| 503 | + const updateSettings = useStore((s) => s.updateSettings) | |
| 504 | + const toast = useStore((s) => s.toast) | |
| 505 | + const [passphrase, setPassphrase] = useState('') | |
| 506 | + const vaulted = isVaultEnabled() | |
| 507 | + | |
| 508 | + return ( | |
| 509 | + <div> | |
| 510 | + <div className="settings-section"> | |
| 511 | + <h3>Default system prompt</h3> | |
| 512 | + <textarea | |
| 513 | + className="text-input" | |
| 514 | + rows={4} | |
| 515 | + placeholder="Applied to new conversations" | |
| 516 | + value={settings.defaultSystemPrompt} | |
| 517 | + onChange={(e) => updateSettings({ defaultSystemPrompt: e.target.value })} | |
| 518 | + /> | |
| 519 | + </div> | |
| 520 | + <div className="settings-section"> | |
| 521 | + <h3>Passphrase lock</h3> | |
| 522 | + <p className="settings-note"> | |
| 523 | + Encrypts your stored keys with AES-GCM (key derived from your passphrase). Honest | |
| 524 | + caveat: once unlocked, a compromised page can still read keys — this protects the data | |
| 525 | + at rest, not a running session. Without a passphrase, keys are stored in plaintext. | |
| 526 | + </p> | |
| 527 | + <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}> | |
| 528 | + <input | |
| 529 | + className="text-input" | |
| 530 | + type="password" | |
| 531 | + placeholder="Passphrase" | |
| 532 | + value={passphrase} | |
| 533 | + onChange={(e) => setPassphrase(e.target.value)} | |
| 534 | + /> | |
| 535 | + {!vaulted ? ( | |
| 536 | + <button | |
| 537 | + className="button primary" | |
| 538 | + disabled={passphrase.length < 4} | |
| 539 | + onClick={async () => { | |
| 540 | + await enableVault(passphrase) | |
| 541 | + setPassphrase('') | |
| 542 | + toast('Passphrase lock enabled', 'success') | |
| 543 | + }} | |
| 544 | + > | |
| 545 | + Enable lock | |
| 546 | + </button> | |
| 547 | + ) : ( | |
| 548 | + <> | |
| 549 | + <button | |
| 550 | + className="button" | |
| 551 | + disabled={passphrase.length < 4} | |
| 552 | + onClick={async () => { | |
| 553 | + try { | |
| 554 | + await persistVault(passphrase) | |
| 555 | + toast('Vault re-encrypted', 'success') | |
| 556 | + } catch { | |
| 557 | + toast('Wrong passphrase', 'error') | |
| 558 | + } | |
| 559 | + }} | |
| 560 | + > | |
| 561 | + Re-encrypt | |
| 562 | + </button> | |
| 563 | + <button | |
| 564 | + className="button danger" | |
| 565 | + disabled={passphrase.length < 4} | |
| 566 | + onClick={async () => { | |
| 567 | + try { | |
| 568 | + await disableVault(passphrase) | |
| 569 | + setPassphrase('') | |
| 570 | + toast('Passphrase lock disabled', 'success') | |
| 571 | + } catch { | |
| 572 | + toast('Wrong passphrase', 'error') | |
| 573 | + } | |
| 574 | + }} | |
| 575 | + > | |
| 576 | + Disable lock | |
| 577 | + </button> | |
| 578 | + </> | |
| 579 | + )} | |
| 580 | + </div> | |
| 581 | + {vaulted && <p className="settings-note" style={{ marginTop: 6 }}>Lock is enabled.</p>} | |
| 582 | + </div> | |
| 583 | + <div className="settings-section"> | |
| 584 | + <h3>Streaming</h3> | |
| 585 | + <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}> | |
| 586 | + <input | |
| 587 | + type="checkbox" | |
| 588 | + checked={settings.streamingEnabled} | |
| 589 | + onChange={(e) => updateSettings({ streamingEnabled: e.target.checked })} | |
| 590 | + /> | |
| 591 | + Stream responses token-by-token | |
| 592 | + </label> | |
| 593 | + </div> | |
| 594 | + </div> | |
| 595 | + ) | |
| 596 | +} | |
added
src/components/Sidebar.tsx
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +/* | |
| 2 | + * Sidebar.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Conversation sidebar: wordmark, search (⌘F), New Chat, grouped rows | |
| 9 | + * (Pinned / Today / Yesterday / Previous 7 Days / Older), footer with | |
| 10 | + * Settings + usage summary. Collapses to a drawer on phones. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { useMemo, useRef } from 'react' | |
| 14 | +import { useStore } from '../state/store' | |
| 15 | +import { matchesSearch } from '../storage/conversations' | |
| 16 | +import type { Conversation } from '../types' | |
| 17 | +import ZyquoGlyph from './ZyquoGlyph' | |
| 18 | +import { IconGear, IconPin, IconPlus, IconSearch, IconTrash } from './icons' | |
| 19 | + | |
| 20 | +const GROUP_ORDER = ['Pinned', 'Today', 'Yesterday', 'Previous 7 Days', 'Older'] as const | |
| 21 | + | |
| 22 | +function groupFor(conversation: Conversation, now: Date): (typeof GROUP_ORDER)[number] { | |
| 23 | + if (conversation.pinned) return 'Pinned' | |
| 24 | + const updated = new Date(conversation.updatedAt) | |
| 25 | + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() | |
| 26 | + if (updated.getTime() >= startOfToday) return 'Today' | |
| 27 | + if (updated.getTime() >= startOfToday - 86_400_000) return 'Yesterday' | |
| 28 | + if (updated.getTime() >= startOfToday - 7 * 86_400_000) return 'Previous 7 Days' | |
| 29 | + return 'Older' | |
| 30 | +} | |
| 31 | + | |
| 32 | +function relativeTime(timestamp: number): string { | |
| 33 | + const delta = Date.now() - timestamp | |
| 34 | + const minutes = Math.floor(delta / 60_000) | |
| 35 | + if (minutes < 1) return 'now' | |
| 36 | + if (minutes < 60) return `${minutes}m` | |
| 37 | + const hours = Math.floor(minutes / 60) | |
| 38 | + if (hours < 24) return `${hours}h` | |
| 39 | + const days = Math.floor(hours / 24) | |
| 40 | + if (days < 7) return `${days}d` | |
| 41 | + return new Date(timestamp).toLocaleDateString() | |
| 42 | +} | |
| 43 | + | |
| 44 | +export default function Sidebar() { | |
| 45 | + const conversations = useStore((s) => s.conversations) | |
| 46 | + const selectedID = useStore((s) => s.selectedID) | |
| 47 | + const searchText = useStore((s) => s.searchText) | |
| 48 | + const setSearchText = useStore((s) => s.setSearchText) | |
| 49 | + const select = useStore((s) => s.select) | |
| 50 | + const newConversation = useStore((s) => s.newConversation) | |
| 51 | + const removeConversation = useStore((s) => s.removeConversation) | |
| 52 | + const updateConversation = useStore((s) => s.updateConversation) | |
| 53 | + const openSettings = useStore((s) => s.openSettings) | |
| 54 | + const sidebarOpen = useStore((s) => s.sidebarOpen) | |
| 55 | + const searchRef = useRef<HTMLInputElement>(null) | |
| 56 | + | |
| 57 | + const groups = useMemo(() => { | |
| 58 | + const now = new Date() | |
| 59 | + const filtered = conversations.filter((c) => matchesSearch(c, searchText)) | |
| 60 | + const byGroup = new Map<string, Conversation[]>() | |
| 61 | + for (const conversation of filtered) { | |
| 62 | + const group = groupFor(conversation, now) | |
| 63 | + const list = byGroup.get(group) ?? [] | |
| 64 | + list.push(conversation) | |
| 65 | + byGroup.set(group, list) | |
| 66 | + } | |
| 67 | + return GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({ | |
| 68 | + name: g, | |
| 69 | + items: byGroup.get(g) ?? [], | |
| 70 | + })) | |
| 71 | + }, [conversations, searchText]) | |
| 72 | + | |
| 73 | + const usage = useMemo(() => { | |
| 74 | + let tokens = 0 | |
| 75 | + let cost = 0 | |
| 76 | + for (const conversation of conversations) { | |
| 77 | + for (const message of conversation.messages) { | |
| 78 | + tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) | |
| 79 | + cost += message.estimatedCost ?? 0 | |
| 80 | + } | |
| 81 | + } | |
| 82 | + return { tokens, cost } | |
| 83 | + }, [conversations]) | |
| 84 | + | |
| 85 | + return ( | |
| 86 | + <aside className={`sidebar${sidebarOpen ? ' open' : ''}`}> | |
| 87 | + <div className="sidebar-wordmark"> | |
| 88 | + <ZyquoGlyph size={20} /> | |
| 89 | + <span>Zyquo Cloud</span> | |
| 90 | + </div> | |
| 91 | + <div className="sidebar-search"> | |
| 92 | + <IconSearch /> | |
| 93 | + <input | |
| 94 | + ref={searchRef} | |
| 95 | + data-search-input | |
| 96 | + placeholder="Search" | |
| 97 | + value={searchText} | |
| 98 | + onChange={(e) => setSearchText(e.target.value)} | |
| 99 | + /> | |
| 100 | + </div> | |
| 101 | + <button className="new-chat-button" onClick={() => newConversation()}> | |
| 102 | + <IconPlus size={12} /> New Chat | |
| 103 | + </button> | |
| 104 | + <div className="conversation-list"> | |
| 105 | + {groups.map((group) => ( | |
| 106 | + <div key={group.name}> | |
| 107 | + <div className="conversation-group-header">{group.name}</div> | |
| 108 | + {group.items.map((conversation) => ( | |
| 109 | + <div | |
| 110 | + key={conversation.id} | |
| 111 | + role="button" | |
| 112 | + tabIndex={0} | |
| 113 | + className={`conversation-row${conversation.id === selectedID ? ' selected' : ''}`} | |
| 114 | + onClick={() => select(conversation.id)} | |
| 115 | + onKeyDown={(e) => e.key === 'Enter' && select(conversation.id)} | |
| 116 | + > | |
| 117 | + <div className="conversation-row-main"> | |
| 118 | + <div className="conversation-row-title">{conversation.title}</div> | |
| 119 | + <div className="conversation-row-sub"> | |
| 120 | + {conversation.modelID} · {relativeTime(conversation.updatedAt)} | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + {conversation.pinned && ( | |
| 124 | + <span className="pin-indicator"> | |
| 125 | + <IconPin size={9} /> | |
| 126 | + </span> | |
| 127 | + )} | |
| 128 | + <span className="conversation-row-actions"> | |
| 129 | + <button | |
| 130 | + className="icon-button" | |
| 131 | + title={conversation.pinned ? 'Unpin' : 'Pin'} | |
| 132 | + onClick={(e) => { | |
| 133 | + e.stopPropagation() | |
| 134 | + updateConversation(conversation.id, { pinned: !conversation.pinned }) | |
| 135 | + }} | |
| 136 | + > | |
| 137 | + <IconPin size={10} /> | |
| 138 | + </button> | |
| 139 | + <button | |
| 140 | + className="icon-button danger" | |
| 141 | + title="Delete" | |
| 142 | + onClick={(e) => { | |
| 143 | + e.stopPropagation() | |
| 144 | + removeConversation(conversation.id) | |
| 145 | + }} | |
| 146 | + > | |
| 147 | + <IconTrash size={10} /> | |
| 148 | + </button> | |
| 149 | + </span> | |
| 150 | + </div> | |
| 151 | + ))} | |
| 152 | + </div> | |
| 153 | + ))} | |
| 154 | + {groups.length === 0 && ( | |
| 155 | + <div className="panel-empty"> | |
| 156 | + {searchText ? 'No conversations match.' : 'No conversations yet.'} | |
| 157 | + </div> | |
| 158 | + )} | |
| 159 | + </div> | |
| 160 | + <div className="sidebar-footer"> | |
| 161 | + <button className="icon-button" title="Settings (⌘,)" onClick={() => openSettings()}> | |
| 162 | + <IconGear /> | |
| 163 | + </button> | |
| 164 | + {usage.tokens > 0 && ( | |
| 165 | + <span> | |
| 166 | + {usage.tokens >= 1_000_000 | |
| 167 | + ? `${(usage.tokens / 1_000_000).toFixed(1)}M tok` | |
| 168 | + : `${(usage.tokens / 1_000).toFixed(1)}K tok`} | |
| 169 | + {usage.cost >= 0.01 | |
| 170 | + ? ` · ~$${usage.cost.toFixed(2)}` | |
| 171 | + : usage.cost > 0 | |
| 172 | + ? ' · <$0.01' | |
| 173 | + : ''} | |
| 174 | + </span> | |
| 175 | + )} | |
| 176 | + </div> | |
| 177 | + </aside> | |
| 178 | + ) | |
| 179 | +} | |
added
src/components/SuggestionGrid.tsx
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +/* | |
| 2 | + * SuggestionGrid.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Empty-conversation state: centered Zyquo mark, greeting, model chip, and | |
| 9 | + * the four suggestion cards (matches the native EmptyStateView). | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import type { ReactNode } from 'react' | |
| 13 | +import ZyquoGlyph from './ZyquoGlyph' | |
| 14 | +import { IconCode, IconDoc, IconGlobe, IconLightbulb } from './icons' | |
| 15 | + | |
| 16 | +const SUGGESTIONS = [ | |
| 17 | + { | |
| 18 | + icon: <IconLightbulb />, | |
| 19 | + title: 'Explain something', | |
| 20 | + prompt: 'Explain how transformer attention works, with a concrete example.', | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + icon: <IconCode />, | |
| 24 | + title: 'Review my code', | |
| 25 | + prompt: 'Review this code for bugs and suggest improvements:\n\n', | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + icon: <IconDoc />, | |
| 29 | + title: 'Summarize a document', | |
| 30 | + prompt: 'Summarize the key points of the following document:\n\n', | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + icon: <IconGlobe />, | |
| 34 | + title: 'Translate', | |
| 35 | + prompt: 'Translate the following text to French, keeping the tone natural:\n\n', | |
| 36 | + }, | |
| 37 | +] | |
| 38 | + | |
| 39 | +export default function SuggestionGrid({ | |
| 40 | + onPick, | |
| 41 | + modelChip, | |
| 42 | +}: { | |
| 43 | + onPick: (prompt: string) => void | |
| 44 | + modelChip?: ReactNode | |
| 45 | +}) { | |
| 46 | + return ( | |
| 47 | + <div className="empty-state"> | |
| 48 | + <ZyquoGlyph size={72} /> | |
| 49 | + <div style={{ textAlign: 'center' }}> | |
| 50 | + <h1 className="empty-title">Welcome to Zyquo Cloud</h1> | |
| 51 | + <p className="empty-subtitle">Your keys, every cloud model, one beautiful chat.</p> | |
| 52 | + </div> | |
| 53 | + {modelChip} | |
| 54 | + <div className="suggestion-grid"> | |
| 55 | + {SUGGESTIONS.map((suggestion) => ( | |
| 56 | + <button | |
| 57 | + key={suggestion.title} | |
| 58 | + className="suggestion-card" | |
| 59 | + onClick={() => onPick(suggestion.prompt)} | |
| 60 | + > | |
| 61 | + <span className="glyph">{suggestion.icon}</span> | |
| 62 | + {suggestion.title} | |
| 63 | + </button> | |
| 64 | + ))} | |
| 65 | + </div> | |
| 66 | + </div> | |
| 67 | + ) | |
| 68 | +} | |
added
src/components/Toasts.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +/* | |
| 2 | + * Toasts.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Toast stack: graceful human errors, success confirmations, and undo for | |
| 9 | + * soft deletes. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import { useStore } from '../state/store' | |
| 13 | +import { IconX } from './icons' | |
| 14 | + | |
| 15 | +export default function Toasts() { | |
| 16 | + const toasts = useStore((s) => s.toasts) | |
| 17 | + const dismissToast = useStore((s) => s.dismissToast) | |
| 18 | + if (toasts.length === 0) return null | |
| 19 | + return ( | |
| 20 | + <div className="toasts"> | |
| 21 | + {toasts.map((toast) => ( | |
| 22 | + <div key={toast.id} className={`toast ${toast.kind}`}> | |
| 23 | + <span style={{ flex: 1 }}>{toast.text}</span> | |
| 24 | + {toast.undo && ( | |
| 25 | + <button | |
| 26 | + className="undo" | |
| 27 | + onClick={() => { | |
| 28 | + toast.undo?.() | |
| 29 | + dismissToast(toast.id) | |
| 30 | + }} | |
| 31 | + > | |
| 32 | + Undo | |
| 33 | + </button> | |
| 34 | + )} | |
| 35 | + <button className="icon-button" onClick={() => dismissToast(toast.id)}> | |
| 36 | + <IconX size={9} /> | |
| 37 | + </button> | |
| 38 | + </div> | |
| 39 | + ))} | |
| 40 | + </div> | |
| 41 | + ) | |
| 42 | +} | |
added
src/components/ZyquoGlyph.tsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +/* | |
| 2 | + * ZyquoGlyph.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * The Zyquo brand mark — the chunky Z with the block caret, reproduced 1:1 | |
| 9 | + * from the native CloudZGlyph (1024 grid, tight box 503×509). Single-tint | |
| 10 | + * via currentColor; defaults to the accent token. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +export default function ZyquoGlyph({ | |
| 14 | + size = 22, | |
| 15 | + color = 'var(--z-accent)', | |
| 16 | +}: { | |
| 17 | + size?: number | |
| 18 | + color?: string | |
| 19 | +}) { | |
| 20 | + return ( | |
| 21 | + <svg | |
| 22 | + width={size} | |
| 23 | + height={size * 1.0119} | |
| 24 | + viewBox="0 0 503 509" | |
| 25 | + xmlns="http://www.w3.org/2000/svg" | |
| 26 | + style={{ color }} | |
| 27 | + aria-label="Zyquo" | |
| 28 | + > | |
| 29 | + <g transform="translate(-267,-257)" fill="none" stroke="currentColor"> | |
| 30 | + <path | |
| 31 | + d="M320 310H704L320 700H556" | |
| 32 | + strokeWidth="106" | |
| 33 | + strokeLinecap="round" | |
| 34 | + strokeLinejoin="round" | |
| 35 | + /> | |
| 36 | + <rect x="653" y="585" width="104" height="168" rx="24" fill="currentColor" stroke="none" /> | |
| 37 | + </g> | |
| 38 | + </svg> | |
| 39 | + ) | |
| 40 | +} | |
added
src/components/icons.tsx
+318 −0
@@ -0,0 +1,318 @@ | ||
| 1 | +/* | |
| 2 | + * icons.tsx | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Minimal inline SVG icon set (stroke-based, currentColor) — no icon | |
| 9 | + * dependency (every dependency is key-theft surface). 16×16 grid. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import type { ReactNode } from 'react' | |
| 13 | + | |
| 14 | +function I({ children, size = 14 }: { children: ReactNode; size?: number }) { | |
| 15 | + return ( | |
| 16 | + <svg | |
| 17 | + width={size} | |
| 18 | + height={size} | |
| 19 | + viewBox="0 0 16 16" | |
| 20 | + fill="none" | |
| 21 | + stroke="currentColor" | |
| 22 | + strokeWidth="1.5" | |
| 23 | + strokeLinecap="round" | |
| 24 | + strokeLinejoin="round" | |
| 25 | + aria-hidden="true" | |
| 26 | + > | |
| 27 | + {children} | |
| 28 | + </svg> | |
| 29 | + ) | |
| 30 | +} | |
| 31 | + | |
| 32 | +export const IconSend = ({ size = 13 }: { size?: number }) => ( | |
| 33 | + <I size={size}> | |
| 34 | + <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" /> | |
| 35 | + </I> | |
| 36 | +) | |
| 37 | +export const IconStop = ({ size = 11 }: { size?: number }) => ( | |
| 38 | + <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| 39 | + <rect x="3" y="3" width="10" height="10" rx="2" /> | |
| 40 | + </svg> | |
| 41 | +) | |
| 42 | +export const IconPlus = ({ size = 13 }: { size?: number }) => ( | |
| 43 | + <I size={size}> | |
| 44 | + <path d="M8 3v10M3 8h10" /> | |
| 45 | + </I> | |
| 46 | +) | |
| 47 | +export const IconSearch = ({ size = 12 }: { size?: number }) => ( | |
| 48 | + <I size={size}> | |
| 49 | + <circle cx="7" cy="7" r="4.5" /> | |
| 50 | + <path d="m10.5 10.5 3 3" /> | |
| 51 | + </I> | |
| 52 | +) | |
| 53 | +export const IconGear = ({ size = 14 }: { size?: number }) => ( | |
| 54 | + <I size={size}> | |
| 55 | + <circle cx="8" cy="8" r="2.2" /> | |
| 56 | + <path d="M8 1.8v2M8 12.2v2M1.8 8h2M12.2 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M12.4 3.6 11 5M5 11l-1.4 1.4" /> | |
| 57 | + </I> | |
| 58 | +) | |
| 59 | +export const IconTrash = ({ size = 12 }: { size?: number }) => ( | |
| 60 | + <I size={size}> | |
| 61 | + <path d="M2.5 4.5h11M6.5 2.5h3M4 4.5 4.8 13.5h6.4L12 4.5M6.5 7v4M9.5 7v4" /> | |
| 62 | + </I> | |
| 63 | +) | |
| 64 | +export const IconPin = ({ size = 11 }: { size?: number }) => ( | |
| 65 | + <I size={size}> | |
| 66 | + <path d="M9.5 2 14 6.5l-3 1-2 4-1.5-1.5L3 14.5 1.5 13l4.5-4.5L4.5 7l4-2z" /> | |
| 67 | + </I> | |
| 68 | +) | |
| 69 | +export const IconCopy = ({ size = 11 }: { size?: number }) => ( | |
| 70 | + <I size={size}> | |
| 71 | + <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /> | |
| 72 | + <path d="M10.5 3.5v-1h-8v8h1" /> | |
| 73 | + </I> | |
| 74 | +) | |
| 75 | +export const IconEdit = ({ size = 11 }: { size?: number }) => ( | |
| 76 | + <I size={size}> | |
| 77 | + <path d="m2.5 13.5.6-3 8-8 2.4 2.4-8 8-3 .6zM9.5 4l2.5 2.5" /> | |
| 78 | + </I> | |
| 79 | +) | |
| 80 | +export const IconRefresh = ({ size = 11 }: { size?: number }) => ( | |
| 81 | + <I size={size}> | |
| 82 | + <path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" /> | |
| 83 | + </I> | |
| 84 | +) | |
| 85 | +export const IconChevronDown = ({ size = 8 }: { size?: number }) => ( | |
| 86 | + <I size={size}> | |
| 87 | + <path d="m3 5.5 5 5 5-5" strokeWidth="2.2" /> | |
| 88 | + </I> | |
| 89 | +) | |
| 90 | +export const IconChevronRight = ({ size = 8 }: { size?: number }) => ( | |
| 91 | + <I size={size}> | |
| 92 | + <path d="m5.5 3 5 5-5 5" strokeWidth="2.2" /> | |
| 93 | + </I> | |
| 94 | +) | |
| 95 | +export const IconStar = ({ size = 11, filled = false }: { size?: number; filled?: boolean }) => ( | |
| 96 | + <svg | |
| 97 | + width={size} | |
| 98 | + height={size} | |
| 99 | + viewBox="0 0 16 16" | |
| 100 | + fill={filled ? 'currentColor' : 'none'} | |
| 101 | + stroke="currentColor" | |
| 102 | + strokeWidth="1.3" | |
| 103 | + strokeLinejoin="round" | |
| 104 | + aria-hidden="true" | |
| 105 | + > | |
| 106 | + <path d="M8 1.5 10 6l4.8.4-3.6 3.2 1 4.7L8 11.8l-4.2 2.5 1-4.7L1.2 6.4 6 6z" /> | |
| 107 | + </svg> | |
| 108 | +) | |
| 109 | +export const IconEye = ({ size = 10 }: { size?: number }) => ( | |
| 110 | + <I size={size}> | |
| 111 | + <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" /> | |
| 112 | + <circle cx="8" cy="8" r="2" /> | |
| 113 | + </I> | |
| 114 | +) | |
| 115 | +export const IconBrain = ({ size = 10 }: { size?: number }) => ( | |
| 116 | + <I size={size}> | |
| 117 | + <path d="M8 2.5c-3 0-5 2-5 4.5 0 1.6.8 3 2 3.8V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.2c1.2-.8 2-2.2 2-3.8 0-2.5-2-4.5-5-4.5z" /> | |
| 118 | + <path d="M6 7.5h4M8 7.5v3" /> | |
| 119 | + </I> | |
| 120 | +) | |
| 121 | +export const IconWrench = ({ size = 10 }: { size?: number }) => ( | |
| 122 | + <I size={size}> | |
| 123 | + <path d="M9.5 6.5 13 3a3.5 3.5 0 0 1-4.6 4.6L4 12a1.4 1.4 0 0 1-2-2l4.4-4.4A3.5 3.5 0 0 1 11 1l-2.5 2.5 1 2 2-1z" /> | |
| 124 | + </I> | |
| 125 | +) | |
| 126 | +export const IconPaperclip = ({ size = 14 }: { size?: number }) => ( | |
| 127 | + <I size={size}> | |
| 128 | + <path d="m13 7.5-5 5a3.2 3.2 0 0 1-4.5-4.5l5.5-5.5a2.2 2.2 0 0 1 3 3L6.7 10.8a1.1 1.1 0 0 1-1.5-1.5L10 4.5" /> | |
| 129 | + </I> | |
| 130 | +) | |
| 131 | +export const IconX = ({ size = 11 }: { size?: number }) => ( | |
| 132 | + <I size={size}> | |
| 133 | + <path d="m3.5 3.5 9 9M12.5 3.5l-9 9" /> | |
| 134 | + </I> | |
| 135 | +) | |
| 136 | +export const IconMenu = ({ size = 15 }: { size?: number }) => ( | |
| 137 | + <I size={size}> | |
| 138 | + <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" /> | |
| 139 | + </I> | |
| 140 | +) | |
| 141 | +export const IconSliders = ({ size = 13 }: { size?: number }) => ( | |
| 142 | + <I size={size}> | |
| 143 | + <path d="M2.5 5h11M2.5 11h11" /> | |
| 144 | + <circle cx="6" cy="5" r="1.6" fill="var(--z-surface)" /> | |
| 145 | + <circle cx="10" cy="11" r="1.6" fill="var(--z-surface)" /> | |
| 146 | + </I> | |
| 147 | +) | |
| 148 | +export const IconQuote = ({ size = 11 }: { size?: number }) => ( | |
| 149 | + <I size={size}> | |
| 150 | + <path d="M3 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3zM9 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3z" /> | |
| 151 | + </I> | |
| 152 | +) | |
| 153 | +export const IconBranch = ({ size = 11 }: { size?: number }) => ( | |
| 154 | + <I size={size}> | |
| 155 | + <circle cx="4" cy="4" r="1.8" /> | |
| 156 | + <circle cx="4" cy="12" r="1.8" /> | |
| 157 | + <circle cx="12" cy="7" r="1.8" /> | |
| 158 | + <path d="M4 5.8v4.4M4 8c0-2 6-1 8-1" /> | |
| 159 | + </I> | |
| 160 | +) | |
| 161 | +export const IconCheck = ({ size = 11 }: { size?: number }) => ( | |
| 162 | + <I size={size}> | |
| 163 | + <path d="m2.5 8.5 3.5 3.5 7.5-8" /> | |
| 164 | + </I> | |
| 165 | +) | |
| 166 | +export const IconDownload = ({ size = 12 }: { size?: number }) => ( | |
| 167 | + <I size={size}> | |
| 168 | + <path d="M8 2.5v8M4.5 7 8 10.5 11.5 7M2.5 13.5h11" /> | |
| 169 | + </I> | |
| 170 | +) | |
| 171 | +export const IconColumns = ({ size = 12 }: { size?: number }) => ( | |
| 172 | + <I size={size}> | |
| 173 | + <rect x="2" y="2.5" width="12" height="11" rx="1.5" /> | |
| 174 | + <path d="M8 2.5v11" /> | |
| 175 | + </I> | |
| 176 | +) | |
| 177 | +export const IconInfo = ({ size = 12 }: { size?: number }) => ( | |
| 178 | + <I size={size}> | |
| 179 | + <circle cx="8" cy="8" r="6" /> | |
| 180 | + <path d="M8 7.5V11M8 5.2v.1" /> | |
| 181 | + </I> | |
| 182 | +) | |
| 183 | +export const IconArrowDown = ({ size = 10 }: { size?: number }) => ( | |
| 184 | + <I size={size}> | |
| 185 | + <path d="M8 3v10M3.5 8.5 8 13l4.5-4.5" /> | |
| 186 | + </I> | |
| 187 | +) | |
| 188 | +export const IconLightbulb = ({ size = 14 }: { size?: number }) => ( | |
| 189 | + <I size={size}> | |
| 190 | + <path d="M8 1.5a4.5 4.5 0 0 1 2.5 8.2c-.5.4-.5 1-.5 1.8h-4c0-.8 0-1.4-.5-1.8A4.5 4.5 0 0 1 8 1.5zM6.5 13.5h3" /> | |
| 191 | + </I> | |
| 192 | +) | |
| 193 | +export const IconCode = ({ size = 14 }: { size?: number }) => ( | |
| 194 | + <I size={size}> | |
| 195 | + <path d="m5 4.5-4 3.5 4 3.5M11 4.5l4 3.5-4 3.5" /> | |
| 196 | + </I> | |
| 197 | +) | |
| 198 | +export const IconDoc = ({ size = 14 }: { size?: number }) => ( | |
| 199 | + <I size={size}> | |
| 200 | + <path d="M4 1.5h5.5L13 5v9.5H4zM9.5 1.5V5H13" /> | |
| 201 | + <path d="M6 8h5M6 10.5h5" /> | |
| 202 | + </I> | |
| 203 | +) | |
| 204 | +export const IconGlobe = ({ size = 14 }: { size?: number }) => ( | |
| 205 | + <I size={size}> | |
| 206 | + <circle cx="8" cy="8" r="6" /> | |
| 207 | + <path d="M2 8h12M8 2c1.8 1.8 2.5 3.8 2.5 6S9.8 12.2 8 14c-1.8-1.8-2.5-3.8-2.5-6S6.2 3.8 8 2z" /> | |
| 208 | + </I> | |
| 209 | +) | |
| 210 | +export const IconMic = ({ size = 13 }: { size?: number }) => ( | |
| 211 | + <I size={size}> | |
| 212 | + <rect x="6" y="1.5" width="4" height="8" rx="2" /> | |
| 213 | + <path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" /> | |
| 214 | + </I> | |
| 215 | +) | |
| 216 | +export const IconSpeaker = ({ size = 11 }: { size?: number }) => ( | |
| 217 | + <I size={size}> | |
| 218 | + <path d="M2.5 6v4h2.5L9 13V3L5 6zM11 5.5a3.5 3.5 0 0 1 0 5" /> | |
| 219 | + </I> | |
| 220 | +) | |
| 221 | +export const IconCommand = ({ size = 13 }: { size?: number }) => ( | |
| 222 | + <I size={size}> | |
| 223 | + <path d="M5.5 5.5h5v5h-5zM5.5 5.5H4a2 2 0 1 1 2-2v1.5M10.5 5.5H12a2 2 0 1 0-2-2v1.5M5.5 10.5H4a2 2 0 1 0 2 2v-1.5M10.5 10.5H12a2 2 0 1 1-2 2v-1.5" /> | |
| 224 | + </I> | |
| 225 | +) | |
| 226 | + | |
| 227 | +/** Provider glyphs — simple distinctive marks per provider. */ | |
| 228 | +export function ProviderGlyph({ provider, size = 11 }: { provider: string; size?: number }) { | |
| 229 | + switch (provider) { | |
| 230 | + case 'openai': | |
| 231 | + return ( | |
| 232 | + <I size={size}> | |
| 233 | + <circle cx="8" cy="8" r="5.5" /> | |
| 234 | + <path d="M8 2.5v11M3.2 5.2l9.6 5.6M3.2 10.8l9.6-5.6" /> | |
| 235 | + </I> | |
| 236 | + ) | |
| 237 | + case 'anthropic': | |
| 238 | + return ( | |
| 239 | + <I size={size}> | |
| 240 | + <path d="M3 13 8 3l5 10M5.2 9.5h5.6" /> | |
| 241 | + </I> | |
| 242 | + ) | |
| 243 | + case 'xai': | |
| 244 | + return ( | |
| 245 | + <I size={size}> | |
| 246 | + <path d="m3 3 10 10M13 3 3 13" /> | |
| 247 | + </I> | |
| 248 | + ) | |
| 249 | + case 'mistral': | |
| 250 | + return ( | |
| 251 | + <I size={size}> | |
| 252 | + <path d="M2.5 12V5M8 12V3M13.5 12V5M2.5 5c2 0 3-2 5.5-2s3.5 2 5.5 2" /> | |
| 253 | + </I> | |
| 254 | + ) | |
| 255 | + case 'gemini': | |
| 256 | + return ( | |
| 257 | + <I size={size}> | |
| 258 | + <path d="M8 1.5C8.5 5 11 7.5 14.5 8 11 8.5 8.5 11 8 14.5 7.5 11 5 8.5 1.5 8 5 7.5 7.5 5 8 1.5z" /> | |
| 259 | + </I> | |
| 260 | + ) | |
| 261 | + case 'qwen': | |
| 262 | + return ( | |
| 263 | + <I size={size}> | |
| 264 | + <circle cx="8" cy="8" r="5.5" /> | |
| 265 | + <path d="m10.5 10.5 3 3" /> | |
| 266 | + </I> | |
| 267 | + ) | |
| 268 | + case 'deepseek': | |
| 269 | + return ( | |
| 270 | + <I size={size}> | |
| 271 | + <path d="M2 9c1.5-4 4-6.5 8-6.5 2 0 3.5.8 4 2-1 .3-2 1-2.5 2.5C10.5 10 7.5 13 4 13c-1 0-1.7-.3-2-1z" /> | |
| 272 | + </I> | |
| 273 | + ) | |
| 274 | + case 'kimi': | |
| 275 | + return ( | |
| 276 | + <I size={size}> | |
| 277 | + <path d="M12.5 10.5A5.5 5.5 0 1 1 5.5 3.5a4.5 4.5 0 0 0 7 7z" /> | |
| 278 | + </I> | |
| 279 | + ) | |
| 280 | + case 'perplexity': | |
| 281 | + return ( | |
| 282 | + <I size={size}> | |
| 283 | + <circle cx="8" cy="8" r="5.5" /> | |
| 284 | + <path d="M6 6.5c0-2.5 4-2.5 4 0 0 1.5-2 1.5-2 3M8 11.5v.1" /> | |
| 285 | + </I> | |
| 286 | + ) | |
| 287 | + case 'together': | |
| 288 | + return ( | |
| 289 | + <I size={size}> | |
| 290 | + <circle cx="5.5" cy="5.5" r="2.5" /> | |
| 291 | + <circle cx="10.5" cy="10.5" r="2.5" /> | |
| 292 | + <circle cx="10.5" cy="5.5" r="2.5" /> | |
| 293 | + <circle cx="5.5" cy="10.5" r="2.5" /> | |
| 294 | + </I> | |
| 295 | + ) | |
| 296 | + case 'deepinfra': | |
| 297 | + return ( | |
| 298 | + <I size={size}> | |
| 299 | + <path d="M8 1.5 14 5v6l-6 3.5L2 11V5z" /> | |
| 300 | + <path d="M8 8v6.5M8 8 2 5M8 8l6-3" /> | |
| 301 | + </I> | |
| 302 | + ) | |
| 303 | + case 'cerebras': | |
| 304 | + return ( | |
| 305 | + <I size={size}> | |
| 306 | + <circle cx="8" cy="8" r="5.5" /> | |
| 307 | + <path d="M8 4.5A3.5 3.5 0 1 0 11.5 8" /> | |
| 308 | + </I> | |
| 309 | + ) | |
| 310 | + default: | |
| 311 | + return ( | |
| 312 | + <I size={size}> | |
| 313 | + <circle cx="8" cy="8" r="5.5" /> | |
| 314 | + <path d="M8 5v3.5" /> | |
| 315 | + </I> | |
| 316 | + ) | |
| 317 | + } | |
| 318 | +} | |
added
src/design/components.css
+1373 −0
@@ -0,0 +1,1373 @@ | ||
| 1 | +/* | |
| 2 | + * components.css | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Component styles — every value comes from tokens.css. Layout mirrors the | |
| 9 | + * native app (sidebar 260 + centered 760 transcript column), responsive: | |
| 10 | + * the sidebar becomes a drawer under 768px. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +/* ---------- Shell ---------- */ | |
| 14 | + | |
| 15 | +.shell { | |
| 16 | + display: flex; | |
| 17 | + height: 100%; | |
| 18 | + background: var(--z-background); | |
| 19 | +} | |
| 20 | + | |
| 21 | +.chat-area { | |
| 22 | + flex: 1; | |
| 23 | + display: flex; | |
| 24 | + flex-direction: column; | |
| 25 | + min-width: 0; | |
| 26 | + height: 100%; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/* ---------- Sidebar ---------- */ | |
| 30 | + | |
| 31 | +.sidebar { | |
| 32 | + width: var(--z-sidebar-width); | |
| 33 | + min-width: var(--z-sidebar-width); | |
| 34 | + display: flex; | |
| 35 | + flex-direction: column; | |
| 36 | + border-right: var(--z-hairline) solid var(--z-border); | |
| 37 | + background: var(--z-background); | |
| 38 | + height: 100%; | |
| 39 | +} | |
| 40 | + | |
| 41 | +.sidebar-wordmark { | |
| 42 | + display: flex; | |
| 43 | + align-items: center; | |
| 44 | + gap: var(--z-space-xs); | |
| 45 | + padding: 12px var(--z-space-md) var(--z-space-xs); | |
| 46 | + font-size: 14px; | |
| 47 | + font-weight: 500; | |
| 48 | + user-select: none; | |
| 49 | +} | |
| 50 | + | |
| 51 | +.sidebar-search { | |
| 52 | + display: flex; | |
| 53 | + align-items: center; | |
| 54 | + gap: 6px; | |
| 55 | + margin: 0 var(--z-space-sm) var(--z-space-xs); | |
| 56 | + padding: 5px var(--z-space-xs); | |
| 57 | + border-radius: var(--z-radius-small); | |
| 58 | + background: color-mix(in srgb, var(--z-surface-secondary) 70%, transparent); | |
| 59 | + color: var(--z-text-tertiary); | |
| 60 | +} | |
| 61 | +.sidebar-search input { | |
| 62 | + flex: 1; | |
| 63 | + border: none; | |
| 64 | + outline: none; | |
| 65 | + background: transparent; | |
| 66 | + font-size: 12.5px; | |
| 67 | + color: var(--z-text-primary); | |
| 68 | + min-width: 0; | |
| 69 | +} | |
| 70 | + | |
| 71 | +.new-chat-button { | |
| 72 | + display: flex; | |
| 73 | + align-items: center; | |
| 74 | + justify-content: center; | |
| 75 | + gap: 6px; | |
| 76 | + margin: 0 var(--z-space-sm) var(--z-space-xs); | |
| 77 | + padding: 7px var(--z-space-sm); | |
| 78 | + border-radius: var(--z-radius-small); | |
| 79 | + background: var(--z-accent); | |
| 80 | + color: var(--z-on-accent); | |
| 81 | + font-size: 13px; | |
| 82 | + font-weight: 500; | |
| 83 | + transition: transform var(--z-motion-hover); | |
| 84 | +} | |
| 85 | +.new-chat-button:active { | |
| 86 | + transform: scale(var(--z-motion-pressed-scale)); | |
| 87 | +} | |
| 88 | + | |
| 89 | +.conversation-list { | |
| 90 | + flex: 1; | |
| 91 | + overflow-y: auto; | |
| 92 | + padding: 0 var(--z-space-xs); | |
| 93 | +} | |
| 94 | + | |
| 95 | +.conversation-group-header { | |
| 96 | + font-size: var(--z-font-size-caption); | |
| 97 | + color: var(--z-text-tertiary); | |
| 98 | + padding: 12px var(--z-space-xs) 2px; | |
| 99 | + user-select: none; | |
| 100 | +} | |
| 101 | + | |
| 102 | +.conversation-row { | |
| 103 | + display: flex; | |
| 104 | + align-items: center; | |
| 105 | + gap: 6px; | |
| 106 | + width: 100%; | |
| 107 | + text-align: left; | |
| 108 | + padding: 5px var(--z-space-xs); | |
| 109 | + border-radius: var(--z-radius-small); | |
| 110 | + margin-bottom: 2px; | |
| 111 | + transition: background var(--z-motion-hover); | |
| 112 | + position: relative; | |
| 113 | +} | |
| 114 | +.conversation-row:hover { | |
| 115 | + background: color-mix(in srgb, var(--z-surface-secondary) 60%, transparent); | |
| 116 | +} | |
| 117 | +.conversation-row.selected { | |
| 118 | + background: var(--z-accent-subtle); | |
| 119 | +} | |
| 120 | +.conversation-row-main { | |
| 121 | + flex: 1; | |
| 122 | + min-width: 0; | |
| 123 | +} | |
| 124 | +.conversation-row-title { | |
| 125 | + font-size: 13px; | |
| 126 | + color: var(--z-text-primary); | |
| 127 | + white-space: nowrap; | |
| 128 | + overflow: hidden; | |
| 129 | + text-overflow: ellipsis; | |
| 130 | +} | |
| 131 | +.conversation-row-sub { | |
| 132 | + font-size: var(--z-font-size-caption); | |
| 133 | + color: var(--z-text-tertiary); | |
| 134 | + white-space: nowrap; | |
| 135 | + overflow: hidden; | |
| 136 | + text-overflow: ellipsis; | |
| 137 | + margin-top: 1px; | |
| 138 | +} | |
| 139 | +.conversation-row-actions { | |
| 140 | + display: none; | |
| 141 | + gap: 4px; | |
| 142 | + color: var(--z-text-tertiary); | |
| 143 | +} | |
| 144 | +.conversation-row:hover .conversation-row-actions { | |
| 145 | + display: flex; | |
| 146 | +} | |
| 147 | +.conversation-row .pin-indicator { | |
| 148 | + color: var(--z-text-tertiary); | |
| 149 | + font-size: 9px; | |
| 150 | +} | |
| 151 | +.conversation-row:hover .pin-indicator { | |
| 152 | + display: none; | |
| 153 | +} | |
| 154 | + | |
| 155 | +.sidebar-footer { | |
| 156 | + display: flex; | |
| 157 | + align-items: center; | |
| 158 | + justify-content: space-between; | |
| 159 | + padding: var(--z-space-xs) var(--z-space-md); | |
| 160 | + border-top: var(--z-hairline) solid var(--z-border); | |
| 161 | + color: var(--z-text-tertiary); | |
| 162 | + font-size: var(--z-font-size-caption); | |
| 163 | +} | |
| 164 | + | |
| 165 | +.icon-button { | |
| 166 | + display: inline-flex; | |
| 167 | + align-items: center; | |
| 168 | + justify-content: center; | |
| 169 | + width: 26px; | |
| 170 | + height: 26px; | |
| 171 | + border-radius: var(--z-radius-small); | |
| 172 | + color: var(--z-text-secondary); | |
| 173 | + transition: background var(--z-motion-hover); | |
| 174 | +} | |
| 175 | +.icon-button:hover { | |
| 176 | + background: var(--z-surface-secondary); | |
| 177 | +} | |
| 178 | +.icon-button.danger:hover { | |
| 179 | + color: var(--z-danger); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/* ---------- Chat header ---------- */ | |
| 183 | + | |
| 184 | +.chat-header { | |
| 185 | + height: var(--z-chat-header-height); | |
| 186 | + min-height: var(--z-chat-header-height); | |
| 187 | + display: grid; | |
| 188 | + grid-template-columns: 1fr auto 1fr; | |
| 189 | + align-items: center; | |
| 190 | + padding: 0 var(--z-space-md); | |
| 191 | + border-bottom: var(--z-hairline) solid var(--z-border); | |
| 192 | + gap: var(--z-space-xs); | |
| 193 | +} | |
| 194 | +.chat-header-left { | |
| 195 | + display: flex; | |
| 196 | + align-items: center; | |
| 197 | + gap: var(--z-space-xs); | |
| 198 | + min-width: 0; | |
| 199 | +} | |
| 200 | +.chat-header-right { | |
| 201 | + display: flex; | |
| 202 | + align-items: center; | |
| 203 | + gap: 2px; | |
| 204 | + justify-content: flex-end; | |
| 205 | +} | |
| 206 | +.chat-title { | |
| 207 | + font-size: 13px; | |
| 208 | + font-weight: 500; | |
| 209 | + max-width: 220px; | |
| 210 | + white-space: nowrap; | |
| 211 | + overflow: hidden; | |
| 212 | + text-overflow: ellipsis; | |
| 213 | + cursor: default; | |
| 214 | +} | |
| 215 | +.chat-title-input { | |
| 216 | + font-size: 13px; | |
| 217 | + font-weight: 500; | |
| 218 | + border: none; | |
| 219 | + outline: none; | |
| 220 | + background: var(--z-surface-secondary); | |
| 221 | + border-radius: var(--z-radius-small); | |
| 222 | + padding: 2px 6px; | |
| 223 | + max-width: 220px; | |
| 224 | +} | |
| 225 | + | |
| 226 | +/* ---------- Model chip ---------- */ | |
| 227 | + | |
| 228 | +.model-chip { | |
| 229 | + display: inline-flex; | |
| 230 | + align-items: center; | |
| 231 | + gap: 6px; | |
| 232 | + padding: 4px var(--z-space-xs); | |
| 233 | + border-radius: var(--z-radius-small); | |
| 234 | + background: var(--z-surface-secondary); | |
| 235 | + font-size: 12.5px; | |
| 236 | + font-weight: 500; | |
| 237 | + color: var(--z-text-primary); | |
| 238 | + transition: transform var(--z-motion-hover); | |
| 239 | + white-space: nowrap; | |
| 240 | + max-width: 60vw; | |
| 241 | + overflow: hidden; | |
| 242 | + text-overflow: ellipsis; | |
| 243 | +} | |
| 244 | +.model-chip:active { | |
| 245 | + transform: scale(var(--z-motion-pressed-scale)); | |
| 246 | +} | |
| 247 | +.model-chip .glyph { | |
| 248 | + color: var(--z-accent); | |
| 249 | + display: inline-flex; | |
| 250 | +} | |
| 251 | +.model-chip .chevron { | |
| 252 | + color: var(--z-text-tertiary); | |
| 253 | + font-size: 8px; | |
| 254 | +} | |
| 255 | + | |
| 256 | +/* ---------- Model menu (command-palette style) ---------- */ | |
| 257 | + | |
| 258 | +.overlay { | |
| 259 | + position: fixed; | |
| 260 | + inset: 0; | |
| 261 | + background: rgba(10, 12, 20, 0.25); | |
| 262 | + display: flex; | |
| 263 | + align-items: flex-start; | |
| 264 | + justify-content: center; | |
| 265 | + padding: 8vh var(--z-space-md) var(--z-space-md); | |
| 266 | + z-index: 100; | |
| 267 | + animation: overlay-in 120ms ease-out; | |
| 268 | +} | |
| 269 | +@keyframes overlay-in { | |
| 270 | + from { | |
| 271 | + opacity: 0; | |
| 272 | + } | |
| 273 | + to { | |
| 274 | + opacity: 1; | |
| 275 | + } | |
| 276 | +} | |
| 277 | + | |
| 278 | +.panel { | |
| 279 | + background: var(--z-surface); | |
| 280 | + border: var(--z-hairline) solid var(--z-border); | |
| 281 | + border-radius: var(--z-radius-large); | |
| 282 | + box-shadow: var(--z-shadow-panel); | |
| 283 | + width: 620px; | |
| 284 | + max-width: 100%; | |
| 285 | + max-height: 72vh; | |
| 286 | + display: flex; | |
| 287 | + flex-direction: column; | |
| 288 | + overflow: hidden; | |
| 289 | + animation: panel-in 150ms ease-out; | |
| 290 | +} | |
| 291 | +@keyframes panel-in { | |
| 292 | + from { | |
| 293 | + opacity: 0; | |
| 294 | + transform: translateY(6px) scale(0.99); | |
| 295 | + } | |
| 296 | + to { | |
| 297 | + opacity: 1; | |
| 298 | + transform: none; | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +.panel-search { | |
| 303 | + display: flex; | |
| 304 | + align-items: center; | |
| 305 | + gap: var(--z-space-xs); | |
| 306 | + padding: var(--z-space-sm) var(--z-space-md); | |
| 307 | + border-bottom: var(--z-hairline) solid var(--z-border); | |
| 308 | + color: var(--z-text-tertiary); | |
| 309 | + flex-shrink: 0; | |
| 310 | +} | |
| 311 | +.panel-search input { | |
| 312 | + flex: 1; | |
| 313 | + border: none; | |
| 314 | + outline: none; | |
| 315 | + background: transparent; | |
| 316 | + font-size: 15px; | |
| 317 | + color: var(--z-text-primary); | |
| 318 | +} | |
| 319 | + | |
| 320 | +.panel-filters { | |
| 321 | + display: flex; | |
| 322 | + gap: 6px; | |
| 323 | + padding: var(--z-space-xs) var(--z-space-md); | |
| 324 | + border-bottom: var(--z-hairline) solid var(--z-border); | |
| 325 | + overflow-x: auto; | |
| 326 | + flex-wrap: nowrap; | |
| 327 | + flex-shrink: 0; | |
| 328 | +} | |
| 329 | +.panel-footer { | |
| 330 | + flex-shrink: 0; | |
| 331 | +} | |
| 332 | +.filter-chip { | |
| 333 | + font-size: var(--z-font-size-caption); | |
| 334 | + padding: 3px 10px; | |
| 335 | + border-radius: 999px; | |
| 336 | + background: var(--z-surface-secondary); | |
| 337 | + color: var(--z-text-secondary); | |
| 338 | + white-space: nowrap; | |
| 339 | + transition: background var(--z-motion-hover); | |
| 340 | +} | |
| 341 | +.filter-chip.active { | |
| 342 | + background: var(--z-accent); | |
| 343 | + color: var(--z-on-accent); | |
| 344 | +} | |
| 345 | + | |
| 346 | +.panel-list { | |
| 347 | + flex: 1; | |
| 348 | + overflow-y: auto; | |
| 349 | + padding: var(--z-space-xs); | |
| 350 | +} | |
| 351 | + | |
| 352 | +.panel-section-header { | |
| 353 | + font-size: var(--z-font-size-caption); | |
| 354 | + color: var(--z-text-tertiary); | |
| 355 | + padding: 10px var(--z-space-xs) 2px; | |
| 356 | + display: flex; | |
| 357 | + align-items: center; | |
| 358 | + gap: 6px; | |
| 359 | + user-select: none; | |
| 360 | +} | |
| 361 | + | |
| 362 | +.model-row { | |
| 363 | + display: flex; | |
| 364 | + align-items: center; | |
| 365 | + gap: var(--z-space-xs); | |
| 366 | + width: 100%; | |
| 367 | + text-align: left; | |
| 368 | + padding: 5px var(--z-space-xs); | |
| 369 | + border-radius: var(--z-radius-small); | |
| 370 | + transition: background var(--z-motion-hover); | |
| 371 | +} | |
| 372 | +.model-row:hover, | |
| 373 | +.model-row.highlighted { | |
| 374 | + background: var(--z-surface-secondary); | |
| 375 | +} | |
| 376 | +.model-row.current { | |
| 377 | + background: var(--z-accent-subtle); | |
| 378 | +} | |
| 379 | +.model-row .glyph { | |
| 380 | + color: var(--z-accent); | |
| 381 | + width: 16px; | |
| 382 | + display: inline-flex; | |
| 383 | + justify-content: center; | |
| 384 | +} | |
| 385 | +.model-row-name { | |
| 386 | + font-size: 12.5px; | |
| 387 | + color: var(--z-text-primary); | |
| 388 | + white-space: nowrap; | |
| 389 | + overflow: hidden; | |
| 390 | + text-overflow: ellipsis; | |
| 391 | +} | |
| 392 | +.model-row-provider { | |
| 393 | + font-size: var(--z-font-size-caption); | |
| 394 | + color: var(--z-text-tertiary); | |
| 395 | +} | |
| 396 | +.model-row-badges { | |
| 397 | + display: flex; | |
| 398 | + align-items: center; | |
| 399 | + gap: 6px; | |
| 400 | + margin-left: auto; | |
| 401 | + color: var(--z-text-tertiary); | |
| 402 | + font-size: var(--z-font-size-caption); | |
| 403 | + flex-shrink: 0; | |
| 404 | +} | |
| 405 | +.capability-badge { | |
| 406 | + font-size: 10px; | |
| 407 | +} | |
| 408 | +.model-row .star { | |
| 409 | + color: var(--z-text-tertiary); | |
| 410 | + font-size: 11px; | |
| 411 | +} | |
| 412 | +.model-row .star.active { | |
| 413 | + color: var(--z-warning); | |
| 414 | +} | |
| 415 | +.badge { | |
| 416 | + font-size: 10px; | |
| 417 | + padding: 1px 6px; | |
| 418 | + border-radius: 999px; | |
| 419 | + background: var(--z-surface-secondary); | |
| 420 | + color: var(--z-text-secondary); | |
| 421 | + white-space: nowrap; | |
| 422 | +} | |
| 423 | +.badge.accent { | |
| 424 | + background: var(--z-accent-subtle); | |
| 425 | + color: var(--z-accent); | |
| 426 | +} | |
| 427 | +.badge.warn { | |
| 428 | + background: color-mix(in srgb, var(--z-warning) 12%, transparent); | |
| 429 | + color: var(--z-warning); | |
| 430 | +} | |
| 431 | + | |
| 432 | +.panel-empty { | |
| 433 | + padding: var(--z-space-xl); | |
| 434 | + text-align: center; | |
| 435 | + color: var(--z-text-tertiary); | |
| 436 | + font-size: 12.5px; | |
| 437 | +} | |
| 438 | + | |
| 439 | +.panel-footer { | |
| 440 | + display: flex; | |
| 441 | + gap: var(--z-space-md); | |
| 442 | + padding: var(--z-space-xs) var(--z-space-md); | |
| 443 | + border-top: var(--z-hairline) solid var(--z-border); | |
| 444 | + color: var(--z-text-tertiary); | |
| 445 | + font-size: var(--z-font-size-caption); | |
| 446 | +} | |
| 447 | +.panel-footer kbd { | |
| 448 | + background: var(--z-surface-secondary); | |
| 449 | + border-radius: 4px; | |
| 450 | + padding: 0 5px; | |
| 451 | + font-size: 10px; | |
| 452 | +} | |
| 453 | + | |
| 454 | +/* ---------- Transcript ---------- */ | |
| 455 | + | |
| 456 | +.transcript { | |
| 457 | + flex: 1; | |
| 458 | + overflow-y: auto; | |
| 459 | + overflow-x: hidden; | |
| 460 | + position: relative; | |
| 461 | +} | |
| 462 | +.transcript-column { | |
| 463 | + max-width: var(--z-max-message-column); | |
| 464 | + margin: 0 auto; | |
| 465 | + padding: var(--z-space-md); | |
| 466 | + display: flex; | |
| 467 | + flex-direction: column; | |
| 468 | + gap: var(--z-turn-rhythm); | |
| 469 | +} | |
| 470 | +.density-compact .transcript-column { | |
| 471 | + gap: 10px; | |
| 472 | +} | |
| 473 | + | |
| 474 | +.turn { | |
| 475 | + display: flex; | |
| 476 | + gap: var(--z-space-xs); | |
| 477 | + animation: message-in var(--z-motion-send); | |
| 478 | +} | |
| 479 | +@keyframes message-in { | |
| 480 | + from { | |
| 481 | + opacity: 0; | |
| 482 | + transform: translateY(4px); | |
| 483 | + } | |
| 484 | + to { | |
| 485 | + opacity: 1; | |
| 486 | + transform: none; | |
| 487 | + } | |
| 488 | +} | |
| 489 | +.turn.user { | |
| 490 | + justify-content: flex-end; | |
| 491 | + padding-left: 60px; | |
| 492 | +} | |
| 493 | +.turn.assistant { | |
| 494 | + padding-right: 60px; | |
| 495 | +} | |
| 496 | + | |
| 497 | +.avatar { | |
| 498 | + width: 26px; | |
| 499 | + height: 26px; | |
| 500 | + min-width: 26px; | |
| 501 | + border-radius: 50%; | |
| 502 | + background: var(--z-accent-subtle); | |
| 503 | + color: var(--z-accent); | |
| 504 | + display: flex; | |
| 505 | + align-items: center; | |
| 506 | + justify-content: center; | |
| 507 | + margin-top: 2px; | |
| 508 | +} | |
| 509 | + | |
| 510 | +.turn-body { | |
| 511 | + display: flex; | |
| 512 | + flex-direction: column; | |
| 513 | + gap: 4px; | |
| 514 | + min-width: 0; | |
| 515 | + max-width: 100%; | |
| 516 | +} | |
| 517 | + | |
| 518 | +.bubble { | |
| 519 | + padding: 10px var(--z-space-sm); | |
| 520 | + border-radius: var(--z-radius-medium); | |
| 521 | + font-size: var(--z-chat-font-size); | |
| 522 | + line-height: var(--z-line-height-body); | |
| 523 | + overflow-wrap: break-word; | |
| 524 | + min-width: 0; | |
| 525 | +} | |
| 526 | +.turn.user .bubble { | |
| 527 | + background: var(--z-accent-subtle); | |
| 528 | + white-space: pre-wrap; | |
| 529 | +} | |
| 530 | +.turn.assistant .bubble { | |
| 531 | + background: var(--z-surface); | |
| 532 | + border: var(--z-hairline) solid var(--z-border); | |
| 533 | +} | |
| 534 | +.bubble .error-note { | |
| 535 | + display: flex; | |
| 536 | + gap: 6px; | |
| 537 | + align-items: flex-start; | |
| 538 | + color: var(--z-danger); | |
| 539 | + background: color-mix(in srgb, var(--z-danger) 8%, transparent); | |
| 540 | + border-radius: var(--z-radius-small); | |
| 541 | + padding: var(--z-space-xs); | |
| 542 | + font-size: calc(var(--z-chat-font-size) - 1px); | |
| 543 | + margin-top: 6px; | |
| 544 | +} | |
| 545 | + | |
| 546 | +.meta-row { | |
| 547 | + display: flex; | |
| 548 | + align-items: center; | |
| 549 | + gap: var(--z-space-xs); | |
| 550 | + font-size: var(--z-font-size-caption); | |
| 551 | + color: var(--z-text-tertiary); | |
| 552 | + opacity: 0; | |
| 553 | + transition: opacity var(--z-motion-hover); | |
| 554 | + min-height: 18px; | |
| 555 | + flex-wrap: wrap; | |
| 556 | +} | |
| 557 | +.turn:hover .meta-row, | |
| 558 | +.meta-row.always { | |
| 559 | + opacity: 1; | |
| 560 | +} | |
| 561 | +.turn.user .meta-row { | |
| 562 | + justify-content: flex-end; | |
| 563 | +} | |
| 564 | +.meta-action { | |
| 565 | + display: inline-flex; | |
| 566 | + align-items: center; | |
| 567 | + gap: 3px; | |
| 568 | + color: var(--z-text-tertiary); | |
| 569 | + border-radius: 4px; | |
| 570 | + padding: 1px 4px; | |
| 571 | + transition: background var(--z-motion-hover); | |
| 572 | + font-size: var(--z-font-size-caption); | |
| 573 | +} | |
| 574 | +.meta-action:hover { | |
| 575 | + background: var(--z-surface-secondary); | |
| 576 | + color: var(--z-text-secondary); | |
| 577 | +} | |
| 578 | + | |
| 579 | +.reasoning-section { | |
| 580 | + margin-bottom: 6px; | |
| 581 | +} | |
| 582 | +.reasoning-toggle { | |
| 583 | + display: inline-flex; | |
| 584 | + align-items: center; | |
| 585 | + gap: 5px; | |
| 586 | + font-size: var(--z-font-size-caption); | |
| 587 | + color: var(--z-text-secondary); | |
| 588 | +} | |
| 589 | +.reasoning-toggle .chev { | |
| 590 | + display: inline-block; | |
| 591 | + transition: transform 120ms ease; | |
| 592 | + font-size: 8px; | |
| 593 | +} | |
| 594 | +.reasoning-toggle .chev.open { | |
| 595 | + transform: rotate(90deg); | |
| 596 | +} | |
| 597 | +.reasoning-body { | |
| 598 | + margin-top: 6px; | |
| 599 | + padding: var(--z-space-xs); | |
| 600 | + background: var(--z-surface-secondary); | |
| 601 | + border-radius: var(--z-radius-small); | |
| 602 | + font-family: var(--z-font-mono); | |
| 603 | + font-size: calc(var(--z-chat-font-size) - 2px); | |
| 604 | + color: var(--z-text-secondary); | |
| 605 | + white-space: pre-wrap; | |
| 606 | + max-height: 320px; | |
| 607 | + overflow-y: auto; | |
| 608 | +} | |
| 609 | + | |
| 610 | +.citations { | |
| 611 | + display: flex; | |
| 612 | + flex-wrap: wrap; | |
| 613 | + gap: 4px; | |
| 614 | + margin-top: 8px; | |
| 615 | +} | |
| 616 | +.citation-chip { | |
| 617 | + display: inline-flex; | |
| 618 | + align-items: center; | |
| 619 | + gap: 5px; | |
| 620 | + padding: 3px 8px; | |
| 621 | + border-radius: 999px; | |
| 622 | + background: var(--z-surface-secondary); | |
| 623 | + font-size: var(--z-font-size-caption); | |
| 624 | + color: var(--z-text-secondary); | |
| 625 | + max-width: 220px; | |
| 626 | +} | |
| 627 | +.citation-chip:hover { | |
| 628 | + text-decoration: none; | |
| 629 | + background: var(--z-accent-subtle); | |
| 630 | +} | |
| 631 | +.citation-chip .num { | |
| 632 | + width: 13px; | |
| 633 | + height: 13px; | |
| 634 | + min-width: 13px; | |
| 635 | + border-radius: 50%; | |
| 636 | + background: var(--z-accent); | |
| 637 | + color: var(--z-on-accent); | |
| 638 | + font-size: 9px; | |
| 639 | + font-weight: 600; | |
| 640 | + display: inline-flex; | |
| 641 | + align-items: center; | |
| 642 | + justify-content: center; | |
| 643 | +} | |
| 644 | +.citation-chip .title { | |
| 645 | + white-space: nowrap; | |
| 646 | + overflow: hidden; | |
| 647 | + text-overflow: ellipsis; | |
| 648 | +} | |
| 649 | + | |
| 650 | +.streaming-caret { | |
| 651 | + display: inline-block; | |
| 652 | + width: 7px; | |
| 653 | + height: 15px; | |
| 654 | + border-radius: 3px; | |
| 655 | + background: var(--z-accent); | |
| 656 | + vertical-align: text-bottom; | |
| 657 | + margin-left: 2px; | |
| 658 | + animation: caret-blink 1.1s ease-in-out infinite; | |
| 659 | +} | |
| 660 | +@keyframes caret-blink { | |
| 661 | + 0%, | |
| 662 | + 100% { | |
| 663 | + opacity: 1; | |
| 664 | + } | |
| 665 | + 50% { | |
| 666 | + opacity: 0.15; | |
| 667 | + } | |
| 668 | +} | |
| 669 | + | |
| 670 | +.jump-pill { | |
| 671 | + position: absolute; | |
| 672 | + bottom: var(--z-space-xs); | |
| 673 | + left: 50%; | |
| 674 | + transform: translateX(-50%); | |
| 675 | + display: flex; | |
| 676 | + align-items: center; | |
| 677 | + gap: 5px; | |
| 678 | + padding: 5px var(--z-space-sm); | |
| 679 | + border-radius: 999px; | |
| 680 | + background: var(--z-surface); | |
| 681 | + border: var(--z-hairline) solid var(--z-border); | |
| 682 | + box-shadow: var(--z-shadow-soft); | |
| 683 | + font-size: var(--z-font-size-caption); | |
| 684 | + color: var(--z-text-secondary); | |
| 685 | + z-index: 5; | |
| 686 | +} | |
| 687 | + | |
| 688 | +.variant-switch { | |
| 689 | + display: inline-flex; | |
| 690 | + align-items: center; | |
| 691 | + gap: 4px; | |
| 692 | + font-size: var(--z-font-size-caption); | |
| 693 | + color: var(--z-text-tertiary); | |
| 694 | +} | |
| 695 | + | |
| 696 | +/* ---------- Markdown ---------- */ | |
| 697 | + | |
| 698 | +.markdown > *:first-child { | |
| 699 | + margin-top: 0; | |
| 700 | +} | |
| 701 | +.markdown > *:last-child { | |
| 702 | + margin-bottom: 0; | |
| 703 | +} | |
| 704 | +.markdown p, | |
| 705 | +.markdown ul, | |
| 706 | +.markdown ol, | |
| 707 | +.markdown blockquote, | |
| 708 | +.markdown table, | |
| 709 | +.markdown pre { | |
| 710 | + margin: 0 0 12px; | |
| 711 | +} | |
| 712 | +.markdown h1, | |
| 713 | +.markdown h2, | |
| 714 | +.markdown h3, | |
| 715 | +.markdown h4, | |
| 716 | +.markdown h5, | |
| 717 | +.markdown h6 { | |
| 718 | + font-weight: 600; | |
| 719 | + margin: 16px 0 8px; | |
| 720 | + line-height: 1.3; | |
| 721 | +} | |
| 722 | +.markdown h1 { | |
| 723 | + font-size: calc(var(--z-chat-font-size) * 1.55); | |
| 724 | +} | |
| 725 | +.markdown h2 { | |
| 726 | + font-size: calc(var(--z-chat-font-size) * 1.35); | |
| 727 | +} | |
| 728 | +.markdown h3 { | |
| 729 | + font-size: calc(var(--z-chat-font-size) * 1.2); | |
| 730 | +} | |
| 731 | +.markdown h4, | |
| 732 | +.markdown h5, | |
| 733 | +.markdown h6 { | |
| 734 | + font-size: calc(var(--z-chat-font-size) * 1.05); | |
| 735 | +} | |
| 736 | +.markdown blockquote { | |
| 737 | + border-left: 3px solid var(--z-accent); | |
| 738 | + border-radius: 2px; | |
| 739 | + padding-left: 12px; | |
| 740 | + color: var(--z-text-secondary); | |
| 741 | +} | |
| 742 | +.markdown ul, | |
| 743 | +.markdown ol { | |
| 744 | + padding-left: 24px; | |
| 745 | +} | |
| 746 | +.markdown li { | |
| 747 | + margin-bottom: 3px; | |
| 748 | +} | |
| 749 | +.markdown code:not(pre code) { | |
| 750 | + font-size: calc(var(--z-chat-font-size) - 1px); | |
| 751 | + background: var(--z-surface-secondary); | |
| 752 | + border-radius: 4px; | |
| 753 | + padding: 1px 5px; | |
| 754 | +} | |
| 755 | +.markdown hr { | |
| 756 | + border: none; | |
| 757 | + border-top: var(--z-hairline) solid var(--z-border); | |
| 758 | + margin: 12px 0; | |
| 759 | +} | |
| 760 | +.markdown table { | |
| 761 | + border-collapse: collapse; | |
| 762 | + border: var(--z-hairline) solid var(--z-border); | |
| 763 | + border-radius: var(--z-radius-small); | |
| 764 | + overflow: hidden; | |
| 765 | + display: block; | |
| 766 | + max-width: 100%; | |
| 767 | + overflow-x: auto; | |
| 768 | + width: fit-content; | |
| 769 | +} | |
| 770 | +.markdown th, | |
| 771 | +.markdown td { | |
| 772 | + padding: 8px 12px; | |
| 773 | + border: var(--z-hairline) solid var(--z-border); | |
| 774 | + font-size: calc(var(--z-chat-font-size) - 0.5px); | |
| 775 | + text-align: left; | |
| 776 | +} | |
| 777 | +.markdown thead th { | |
| 778 | + background: var(--z-surface-secondary); | |
| 779 | + font-weight: 600; | |
| 780 | +} | |
| 781 | +.markdown tbody tr:nth-child(odd) { | |
| 782 | + background: color-mix(in srgb, var(--z-surface-secondary) 40%, transparent); | |
| 783 | +} | |
| 784 | +.markdown img { | |
| 785 | + max-width: 100%; | |
| 786 | + border-radius: var(--z-radius-small); | |
| 787 | +} | |
| 788 | +.markdown input[type='checkbox'] { | |
| 789 | + accent-color: var(--z-accent); | |
| 790 | + margin-right: 4px; | |
| 791 | +} | |
| 792 | + | |
| 793 | +/* Code blocks */ | |
| 794 | +.code-block { | |
| 795 | + background: var(--z-surface-secondary); | |
| 796 | + border-radius: var(--z-radius-medium); | |
| 797 | + overflow: hidden; | |
| 798 | +} | |
| 799 | +.code-block-header { | |
| 800 | + display: flex; | |
| 801 | + align-items: center; | |
| 802 | + justify-content: space-between; | |
| 803 | + padding: 8px 12px 0; | |
| 804 | + font-size: var(--z-font-size-caption); | |
| 805 | + color: var(--z-text-secondary); | |
| 806 | + text-transform: uppercase; | |
| 807 | + letter-spacing: 0.04em; | |
| 808 | +} | |
| 809 | +.code-block-actions { | |
| 810 | + display: flex; | |
| 811 | + gap: 4px; | |
| 812 | + opacity: 0; | |
| 813 | + transition: opacity var(--z-motion-hover); | |
| 814 | +} | |
| 815 | +.code-block:hover .code-block-actions, | |
| 816 | +.code-block-actions.confirming { | |
| 817 | + opacity: 1; | |
| 818 | +} | |
| 819 | +.code-block-copy { | |
| 820 | + font-size: var(--z-font-size-caption); | |
| 821 | + color: var(--z-text-secondary); | |
| 822 | + text-transform: none; | |
| 823 | + letter-spacing: normal; | |
| 824 | + padding: 1px 6px; | |
| 825 | + border-radius: 4px; | |
| 826 | +} | |
| 827 | +.code-block-copy:hover { | |
| 828 | + background: color-mix(in srgb, var(--z-border) 50%, transparent); | |
| 829 | +} | |
| 830 | +.code-block-copy.ok { | |
| 831 | + color: var(--z-success); | |
| 832 | +} | |
| 833 | +.code-block pre { | |
| 834 | + margin: 0; | |
| 835 | + padding: 4px 12px 8px; | |
| 836 | + overflow-x: auto; | |
| 837 | + font-size: calc(var(--z-chat-font-size) - 1px); | |
| 838 | + line-height: 1.5; | |
| 839 | +} | |
| 840 | +.code-block pre.wrap { | |
| 841 | + white-space: pre-wrap; | |
| 842 | + overflow-wrap: break-word; | |
| 843 | +} | |
| 844 | +.code-block code { | |
| 845 | + background: none; | |
| 846 | + padding: 0; | |
| 847 | + font-size: inherit; | |
| 848 | +} | |
| 849 | + | |
| 850 | +/* highlight.js theme mapped to Zyquo syntax tokens */ | |
| 851 | +.hljs-keyword, | |
| 852 | +.hljs-selector-tag, | |
| 853 | +.hljs-built_in, | |
| 854 | +.hljs-tag { | |
| 855 | + color: var(--z-code-keyword); | |
| 856 | +} | |
| 857 | +.hljs-string, | |
| 858 | +.hljs-regexp, | |
| 859 | +.hljs-addition { | |
| 860 | + color: var(--z-code-string); | |
| 861 | +} | |
| 862 | +.hljs-comment, | |
| 863 | +.hljs-quote, | |
| 864 | +.hljs-deletion, | |
| 865 | +.hljs-meta { | |
| 866 | + color: var(--z-code-comment); | |
| 867 | +} | |
| 868 | +.hljs-number, | |
| 869 | +.hljs-literal, | |
| 870 | +.hljs-symbol { | |
| 871 | + color: var(--z-code-number); | |
| 872 | +} | |
| 873 | +.hljs-type, | |
| 874 | +.hljs-class .hljs-title, | |
| 875 | +.hljs-title.class_ { | |
| 876 | + color: var(--z-code-type); | |
| 877 | +} | |
| 878 | +.hljs-function .hljs-title, | |
| 879 | +.hljs-title.function_ { | |
| 880 | + color: var(--z-code-function); | |
| 881 | +} | |
| 882 | +.hljs-attr, | |
| 883 | +.hljs-property, | |
| 884 | +.hljs-variable { | |
| 885 | + color: var(--z-code-property); | |
| 886 | +} | |
| 887 | +.hljs-attribute, | |
| 888 | +.hljs-name { | |
| 889 | + color: var(--z-code-attribute); | |
| 890 | +} | |
| 891 | + | |
| 892 | +/* ---------- Input bar ---------- */ | |
| 893 | + | |
| 894 | +.input-dock { | |
| 895 | + padding: 0 var(--z-space-sm) var(--z-space-sm); | |
| 896 | +} | |
| 897 | +.input-bar { | |
| 898 | + max-width: var(--z-max-message-column); | |
| 899 | + margin: 0 auto; | |
| 900 | + background: var(--z-surface); | |
| 901 | + border: var(--z-hairline) solid var(--z-border); | |
| 902 | + border-radius: var(--z-radius-large); | |
| 903 | + box-shadow: var(--z-shadow-soft); | |
| 904 | + padding: var(--z-space-sm); | |
| 905 | + display: flex; | |
| 906 | + flex-direction: column; | |
| 907 | + gap: var(--z-space-xs); | |
| 908 | +} | |
| 909 | +.input-bar.drag-target { | |
| 910 | + border: 1.5px dashed var(--z-accent); | |
| 911 | +} | |
| 912 | +.attachment-strip { | |
| 913 | + display: flex; | |
| 914 | + gap: var(--z-space-xs); | |
| 915 | + overflow-x: auto; | |
| 916 | +} | |
| 917 | +.attachment-thumb { | |
| 918 | + position: relative; | |
| 919 | + width: var(--z-attachment-thumb); | |
| 920 | + height: var(--z-attachment-thumb); | |
| 921 | + min-width: var(--z-attachment-thumb); | |
| 922 | + border-radius: var(--z-radius-small); | |
| 923 | + overflow: hidden; | |
| 924 | + background: var(--z-surface-secondary); | |
| 925 | + display: flex; | |
| 926 | + align-items: center; | |
| 927 | + justify-content: center; | |
| 928 | + font-size: 9px; | |
| 929 | + color: var(--z-text-secondary); | |
| 930 | + text-align: center; | |
| 931 | + padding: 2px; | |
| 932 | +} | |
| 933 | +.attachment-thumb img { | |
| 934 | + width: 100%; | |
| 935 | + height: 100%; | |
| 936 | + object-fit: cover; | |
| 937 | +} | |
| 938 | +.attachment-remove { | |
| 939 | + position: absolute; | |
| 940 | + top: 2px; | |
| 941 | + right: 2px; | |
| 942 | + width: 16px; | |
| 943 | + height: 16px; | |
| 944 | + border-radius: 50%; | |
| 945 | + background: var(--z-text-primary); | |
| 946 | + color: var(--z-background); | |
| 947 | + font-size: 10px; | |
| 948 | + line-height: 1; | |
| 949 | + display: flex; | |
| 950 | + align-items: center; | |
| 951 | + justify-content: center; | |
| 952 | +} | |
| 953 | +.input-row { | |
| 954 | + display: flex; | |
| 955 | + align-items: flex-end; | |
| 956 | + gap: var(--z-space-xs); | |
| 957 | +} | |
| 958 | +.input-editor { | |
| 959 | + flex: 1; | |
| 960 | + border: none; | |
| 961 | + outline: none; | |
| 962 | + background: transparent; | |
| 963 | + resize: none; | |
| 964 | + font-size: var(--z-chat-font-size); | |
| 965 | + line-height: var(--z-line-height-body); | |
| 966 | + max-height: 200px; | |
| 967 | + min-height: 22px; | |
| 968 | + padding: 2px 0; | |
| 969 | + color: var(--z-text-primary); | |
| 970 | +} | |
| 971 | +.send-button { | |
| 972 | + width: 28px; | |
| 973 | + height: 28px; | |
| 974 | + min-width: 28px; | |
| 975 | + border-radius: 50%; | |
| 976 | + background: var(--z-accent); | |
| 977 | + color: var(--z-on-accent); | |
| 978 | + display: flex; | |
| 979 | + align-items: center; | |
| 980 | + justify-content: center; | |
| 981 | + transition: transform var(--z-motion-hover), background var(--z-motion-hover); | |
| 982 | +} | |
| 983 | +.send-button:disabled { | |
| 984 | + background: var(--z-surface-secondary); | |
| 985 | + color: var(--z-text-tertiary); | |
| 986 | + cursor: default; | |
| 987 | +} | |
| 988 | +.send-button.stop { | |
| 989 | + background: var(--z-danger); | |
| 990 | +} | |
| 991 | +.send-button:active:not(:disabled) { | |
| 992 | + transform: scale(var(--z-motion-pressed-scale)); | |
| 993 | +} | |
| 994 | +.input-hud { | |
| 995 | + display: flex; | |
| 996 | + align-items: center; | |
| 997 | + gap: var(--z-space-xs); | |
| 998 | + font-size: var(--z-font-size-caption); | |
| 999 | + color: var(--z-text-tertiary); | |
| 1000 | + flex-wrap: wrap; | |
| 1001 | +} | |
| 1002 | +.context-bar { | |
| 1003 | + flex: 1; | |
| 1004 | + min-width: 60px; | |
| 1005 | + max-width: 160px; | |
| 1006 | + height: 3px; | |
| 1007 | + border-radius: 2px; | |
| 1008 | + background: var(--z-surface-secondary); | |
| 1009 | + overflow: hidden; | |
| 1010 | +} | |
| 1011 | +.context-bar-fill { | |
| 1012 | + height: 100%; | |
| 1013 | + background: var(--z-accent); | |
| 1014 | + border-radius: 2px; | |
| 1015 | + transition: width 300ms ease; | |
| 1016 | +} | |
| 1017 | +.context-bar-fill.warn { | |
| 1018 | + background: var(--z-warning); | |
| 1019 | +} | |
| 1020 | + | |
| 1021 | +/* ---------- Empty state ---------- */ | |
| 1022 | + | |
| 1023 | +.empty-state { | |
| 1024 | + flex: 1; | |
| 1025 | + display: flex; | |
| 1026 | + flex-direction: column; | |
| 1027 | + align-items: center; | |
| 1028 | + justify-content: center; | |
| 1029 | + gap: var(--z-space-lg); | |
| 1030 | + padding: var(--z-space-xl); | |
| 1031 | + overflow-y: auto; | |
| 1032 | +} | |
| 1033 | +.empty-title { | |
| 1034 | + font-size: var(--z-font-size-title); | |
| 1035 | + font-weight: 600; | |
| 1036 | + margin: 0; | |
| 1037 | +} | |
| 1038 | +.empty-subtitle { | |
| 1039 | + color: var(--z-text-secondary); | |
| 1040 | + margin: 0; | |
| 1041 | +} | |
| 1042 | +.suggestion-grid { | |
| 1043 | + display: grid; | |
| 1044 | + grid-template-columns: 1fr 1fr; | |
| 1045 | + gap: var(--z-space-sm); | |
| 1046 | + max-width: 460px; | |
| 1047 | + width: 100%; | |
| 1048 | +} | |
| 1049 | +.suggestion-card { | |
| 1050 | + display: flex; | |
| 1051 | + align-items: center; | |
| 1052 | + gap: var(--z-space-xs); | |
| 1053 | + text-align: left; | |
| 1054 | + padding: var(--z-space-sm); | |
| 1055 | + background: var(--z-surface); | |
| 1056 | + border: var(--z-hairline) solid var(--z-border); | |
| 1057 | + border-radius: var(--z-radius-medium); | |
| 1058 | + font-size: 13px; | |
| 1059 | + transition: transform var(--z-motion-hover), background var(--z-motion-hover); | |
| 1060 | +} | |
| 1061 | +.suggestion-card:hover { | |
| 1062 | + background: var(--z-surface-secondary); | |
| 1063 | +} | |
| 1064 | +.suggestion-card:active { | |
| 1065 | + transform: scale(var(--z-motion-pressed-scale)); | |
| 1066 | +} | |
| 1067 | +.suggestion-card .glyph { | |
| 1068 | + color: var(--z-accent); | |
| 1069 | + width: 20px; | |
| 1070 | + display: inline-flex; | |
| 1071 | + justify-content: center; | |
| 1072 | +} | |
| 1073 | + | |
| 1074 | +/* ---------- First run ---------- */ | |
| 1075 | + | |
| 1076 | +.first-run-note { | |
| 1077 | + max-width: 560px; | |
| 1078 | + margin: 0 auto; | |
| 1079 | + background: var(--z-surface); | |
| 1080 | + border: var(--z-hairline) solid var(--z-border); | |
| 1081 | + border-radius: var(--z-radius-medium); | |
| 1082 | + padding: var(--z-space-md); | |
| 1083 | + font-size: 12.5px; | |
| 1084 | + color: var(--z-text-secondary); | |
| 1085 | + display: flex; | |
| 1086 | + flex-direction: column; | |
| 1087 | + gap: var(--z-space-xs); | |
| 1088 | +} | |
| 1089 | +.first-run-note strong { | |
| 1090 | + color: var(--z-text-primary); | |
| 1091 | +} | |
| 1092 | +.first-run-actions { | |
| 1093 | + display: flex; | |
| 1094 | + gap: var(--z-space-xs); | |
| 1095 | + justify-content: flex-end; | |
| 1096 | +} | |
| 1097 | +.button { | |
| 1098 | + padding: 6px 14px; | |
| 1099 | + border-radius: var(--z-radius-small); | |
| 1100 | + font-size: 12.5px; | |
| 1101 | + font-weight: 500; | |
| 1102 | + background: var(--z-surface-secondary); | |
| 1103 | + color: var(--z-text-primary); | |
| 1104 | + transition: transform var(--z-motion-hover); | |
| 1105 | +} | |
| 1106 | +.button.primary { | |
| 1107 | + background: var(--z-accent); | |
| 1108 | + color: var(--z-on-accent); | |
| 1109 | +} | |
| 1110 | +.button.danger { | |
| 1111 | + background: color-mix(in srgb, var(--z-danger) 10%, transparent); | |
| 1112 | + color: var(--z-danger); | |
| 1113 | +} | |
| 1114 | +.button:active { | |
| 1115 | + transform: scale(var(--z-motion-pressed-scale)); | |
| 1116 | +} | |
| 1117 | +.button:disabled { | |
| 1118 | + opacity: 0.5; | |
| 1119 | + cursor: default; | |
| 1120 | +} | |
| 1121 | + | |
| 1122 | +/* ---------- Settings ---------- */ | |
| 1123 | + | |
| 1124 | +.settings-panel { | |
| 1125 | + width: 760px; | |
| 1126 | + max-width: 100%; | |
| 1127 | + height: 560px; | |
| 1128 | + max-height: 85vh; | |
| 1129 | +} | |
| 1130 | +.settings-body { | |
| 1131 | + display: flex; | |
| 1132 | + flex: 1; | |
| 1133 | + min-height: 0; | |
| 1134 | +} | |
| 1135 | +.settings-tabs { | |
| 1136 | + width: 168px; | |
| 1137 | + min-width: 168px; | |
| 1138 | + border-right: var(--z-hairline) solid var(--z-border); | |
| 1139 | + padding: var(--z-space-xs); | |
| 1140 | + display: flex; | |
| 1141 | + flex-direction: column; | |
| 1142 | + gap: 2px; | |
| 1143 | +} | |
| 1144 | +.settings-tab { | |
| 1145 | + text-align: left; | |
| 1146 | + padding: 6px 10px; | |
| 1147 | + border-radius: var(--z-radius-small); | |
| 1148 | + font-size: 13px; | |
| 1149 | + color: var(--z-text-secondary); | |
| 1150 | + transition: background var(--z-motion-hover); | |
| 1151 | +} | |
| 1152 | +.settings-tab:hover { | |
| 1153 | + background: var(--z-surface-secondary); | |
| 1154 | +} | |
| 1155 | +.settings-tab.active { | |
| 1156 | + background: var(--z-accent-subtle); | |
| 1157 | + color: var(--z-text-primary); | |
| 1158 | +} | |
| 1159 | +.settings-content { | |
| 1160 | + flex: 1; | |
| 1161 | + overflow-y: auto; | |
| 1162 | + padding: var(--z-space-md); | |
| 1163 | + min-width: 0; | |
| 1164 | +} | |
| 1165 | +.settings-content h3 { | |
| 1166 | + margin: 0 0 var(--z-space-sm); | |
| 1167 | + font-size: 14px; | |
| 1168 | +} | |
| 1169 | +.settings-row { | |
| 1170 | + display: flex; | |
| 1171 | + align-items: center; | |
| 1172 | + gap: var(--z-space-xs); | |
| 1173 | + padding: var(--z-space-xs) 0; | |
| 1174 | + border-bottom: var(--z-hairline) solid var(--z-border); | |
| 1175 | + flex-wrap: wrap; | |
| 1176 | +} | |
| 1177 | +.settings-row:last-child { | |
| 1178 | + border-bottom: none; | |
| 1179 | +} | |
| 1180 | +.provider-name { | |
| 1181 | + display: flex; | |
| 1182 | + align-items: center; | |
| 1183 | + gap: 6px; | |
| 1184 | + font-size: 13px; | |
| 1185 | + font-weight: 500; | |
| 1186 | + width: 140px; | |
| 1187 | + min-width: 140px; | |
| 1188 | +} | |
| 1189 | +.status-dot { | |
| 1190 | + width: 8px; | |
| 1191 | + height: 8px; | |
| 1192 | + border-radius: 50%; | |
| 1193 | + background: var(--z-border); | |
| 1194 | +} | |
| 1195 | +.status-dot.ok { | |
| 1196 | + background: var(--z-success); | |
| 1197 | +} | |
| 1198 | +.status-dot.fail { | |
| 1199 | + background: var(--z-danger); | |
| 1200 | +} | |
| 1201 | +.status-dot.saved { | |
| 1202 | + background: var(--z-text-tertiary); | |
| 1203 | +} | |
| 1204 | +.key-input { | |
| 1205 | + flex: 1; | |
| 1206 | + min-width: 160px; | |
| 1207 | + font-family: var(--z-font-mono); | |
| 1208 | + font-size: 11px; | |
| 1209 | + padding: 5px 8px; | |
| 1210 | + border: var(--z-hairline) solid var(--z-border); | |
| 1211 | + border-radius: var(--z-radius-small); | |
| 1212 | + background: var(--z-background); | |
| 1213 | + outline: none; | |
| 1214 | +} | |
| 1215 | +.key-input:focus { | |
| 1216 | + border-color: var(--z-accent); | |
| 1217 | +} | |
| 1218 | +.settings-sub { | |
| 1219 | + font-size: var(--z-font-size-caption); | |
| 1220 | + color: var(--z-text-tertiary); | |
| 1221 | + width: 100%; | |
| 1222 | +} | |
| 1223 | +.settings-sub.ok { | |
| 1224 | + color: var(--z-success); | |
| 1225 | +} | |
| 1226 | +.settings-sub.fail { | |
| 1227 | + color: var(--z-danger); | |
| 1228 | +} | |
| 1229 | +.select, | |
| 1230 | +.number-input, | |
| 1231 | +.text-input { | |
| 1232 | + padding: 5px 8px; | |
| 1233 | + border: var(--z-hairline) solid var(--z-border); | |
| 1234 | + border-radius: var(--z-radius-small); | |
| 1235 | + background: var(--z-background); | |
| 1236 | + font-size: 12.5px; | |
| 1237 | + outline: none; | |
| 1238 | +} | |
| 1239 | +.text-input:focus, | |
| 1240 | +.number-input:focus { | |
| 1241 | + border-color: var(--z-accent); | |
| 1242 | +} | |
| 1243 | +.settings-section { | |
| 1244 | + margin-bottom: var(--z-space-lg); | |
| 1245 | +} | |
| 1246 | +.settings-note { | |
| 1247 | + font-size: var(--z-font-size-caption); | |
| 1248 | + color: var(--z-text-tertiary); | |
| 1249 | + line-height: 1.5; | |
| 1250 | +} | |
| 1251 | +textarea.text-input { | |
| 1252 | + width: 100%; | |
| 1253 | + resize: vertical; | |
| 1254 | + font-family: inherit; | |
| 1255 | +} | |
| 1256 | + | |
| 1257 | +/* ---------- Toasts ---------- */ | |
| 1258 | + | |
| 1259 | +.toasts { | |
| 1260 | + position: fixed; | |
| 1261 | + bottom: var(--z-space-md); | |
| 1262 | + right: var(--z-space-md); | |
| 1263 | + display: flex; | |
| 1264 | + flex-direction: column; | |
| 1265 | + gap: var(--z-space-xs); | |
| 1266 | + z-index: 200; | |
| 1267 | + max-width: min(380px, calc(100vw - 32px)); | |
| 1268 | +} | |
| 1269 | +.toast { | |
| 1270 | + display: flex; | |
| 1271 | + align-items: center; | |
| 1272 | + gap: var(--z-space-xs); | |
| 1273 | + background: var(--z-surface); | |
| 1274 | + border: var(--z-hairline) solid var(--z-border); | |
| 1275 | + border-left: 3px solid var(--z-accent); | |
| 1276 | + border-radius: var(--z-radius-medium); | |
| 1277 | + box-shadow: var(--z-shadow-soft); | |
| 1278 | + padding: var(--z-space-xs) var(--z-space-sm); | |
| 1279 | + font-size: 12.5px; | |
| 1280 | + animation: message-in var(--z-motion-send); | |
| 1281 | +} | |
| 1282 | +.toast.error { | |
| 1283 | + border-left-color: var(--z-danger); | |
| 1284 | +} | |
| 1285 | +.toast.success { | |
| 1286 | + border-left-color: var(--z-success); | |
| 1287 | +} | |
| 1288 | +.toast .undo { | |
| 1289 | + color: var(--z-accent); | |
| 1290 | + font-weight: 500; | |
| 1291 | +} | |
| 1292 | + | |
| 1293 | +/* ---------- Drawer / responsive ---------- */ | |
| 1294 | + | |
| 1295 | +.drawer-scrim { | |
| 1296 | + display: none; | |
| 1297 | +} | |
| 1298 | +.menu-button { | |
| 1299 | + display: none; | |
| 1300 | +} | |
| 1301 | + | |
| 1302 | +@media (max-width: 768px) { | |
| 1303 | + .sidebar { | |
| 1304 | + position: fixed; | |
| 1305 | + left: 0; | |
| 1306 | + top: 0; | |
| 1307 | + bottom: 0; | |
| 1308 | + z-index: 60; | |
| 1309 | + transform: translateX(-100%); | |
| 1310 | + transition: transform 200ms ease; | |
| 1311 | + box-shadow: none; | |
| 1312 | + width: min(300px, 85vw); | |
| 1313 | + min-width: 0; | |
| 1314 | + } | |
| 1315 | + .sidebar.open { | |
| 1316 | + transform: none; | |
| 1317 | + box-shadow: var(--z-shadow-panel); | |
| 1318 | + } | |
| 1319 | + .drawer-scrim { | |
| 1320 | + display: block; | |
| 1321 | + position: fixed; | |
| 1322 | + inset: 0; | |
| 1323 | + background: rgba(10, 12, 20, 0.3); | |
| 1324 | + z-index: 50; | |
| 1325 | + } | |
| 1326 | + .menu-button { | |
| 1327 | + display: inline-flex; | |
| 1328 | + } | |
| 1329 | + .turn.user { | |
| 1330 | + padding-left: 24px; | |
| 1331 | + } | |
| 1332 | + .turn.assistant { | |
| 1333 | + padding-right: 12px; | |
| 1334 | + } | |
| 1335 | + .avatar { | |
| 1336 | + display: none; | |
| 1337 | + } | |
| 1338 | + .overlay { | |
| 1339 | + padding: 4vh var(--z-space-xs) var(--z-space-xs); | |
| 1340 | + } | |
| 1341 | + .settings-body { | |
| 1342 | + flex-direction: column; | |
| 1343 | + } | |
| 1344 | + .settings-tabs { | |
| 1345 | + width: 100%; | |
| 1346 | + min-width: 0; | |
| 1347 | + flex-direction: row; | |
| 1348 | + overflow-x: auto; | |
| 1349 | + border-right: none; | |
| 1350 | + border-bottom: var(--z-hairline) solid var(--z-border); | |
| 1351 | + } | |
| 1352 | + .settings-panel { | |
| 1353 | + height: 90vh; | |
| 1354 | + } | |
| 1355 | + .icon-button, | |
| 1356 | + .meta-action, | |
| 1357 | + .conversation-row { | |
| 1358 | + min-height: 32px; | |
| 1359 | + } | |
| 1360 | + .chat-title { | |
| 1361 | + max-width: 120px; | |
| 1362 | + } | |
| 1363 | +} | |
| 1364 | + | |
| 1365 | +/* Focus mode hides the sidebar on desktop */ | |
| 1366 | +.focus-mode .sidebar { | |
| 1367 | + display: none; | |
| 1368 | +} | |
| 1369 | +@media (max-width: 768px) { | |
| 1370 | + .focus-mode .sidebar { | |
| 1371 | + display: flex; | |
| 1372 | + } | |
| 1373 | +} | |
added
src/features/catalogHelpers.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +/* | |
| 2 | + * catalogHelpers.ts | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Catalog conveniences shared by the store and UI: re-exports of the ported | |
| 9 | + * catalog queries plus cost/estimation helpers. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import { pricingCost, type AIModel, type TokenUsage } from '../types' | |
| 13 | + | |
| 14 | +export { CATALOG, cheapestModel, defaultModel, findModel, modelsFor, rankModel } from '../providers/catalog' | |
| 15 | + | |
| 16 | +/** Estimated USD cost of a usage record for a model (0 when unpriced). */ | |
| 17 | +export function pricedCost(model: AIModel, usage: TokenUsage): number { | |
| 18 | + if (!model.pricing) return 0 | |
| 19 | + return pricingCost(model.pricing, usage.inputTokens, usage.outputTokens) | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Rough token estimate for context-window accounting (≈4 chars/token). */ | |
| 23 | +export function estimateTokens(text: string): number { | |
| 24 | + return Math.ceil(text.length / 4) | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** "$1.25 / $10" per-MTok badge, or "—" when unpriced. */ | |
| 28 | +export function priceBadge(model: AIModel): string { | |
| 29 | + if (!model.pricing) return '—' | |
| 30 | + const fmt = (v: number) => (v >= 1 ? `$${v.toFixed(2).replace(/\.?0+$/, '')}` : `$${v}`) | |
| 31 | + return `${fmt(model.pricing.inputPerMTok)} / ${fmt(model.pricing.outputPerMTok)}` | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** "$0.0042" style cost display. */ | |
| 35 | +export function costLabel(cost: number): string { | |
| 36 | + if (cost <= 0) return '' | |
| 37 | + return `~$${cost.toFixed(4)}` | |
| 38 | +} | |
added
src/features/exporters.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +/* | |
| 2 | + * exporters.ts | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * Single-conversation exports (Markdown / JSON) and the all-data backup | |
| 9 | + * download — client-side file downloads, no server. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import { exportAll } from '../storage/backup' | |
| 13 | +import type { Conversation } from '../types' | |
| 14 | + | |
| 15 | +function download(filename: string, content: string, type: string): void { | |
| 16 | + const blob = new Blob([content], { type }) | |
| 17 | + const url = URL.createObjectURL(blob) | |
| 18 | + const a = document.createElement('a') | |
| 19 | + a.href = url | |
| 20 | + a.download = filename | |
| 21 | + a.click() | |
| 22 | + URL.revokeObjectURL(url) | |
| 23 | +} | |
| 24 | + | |
| 25 | +function safeName(title: string): string { | |
| 26 | + return title.replace(/[/\\:*?"<>|]/g, '-').slice(0, 80) | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function exportConversationMarkdown(conversation: Conversation): void { | |
| 30 | + const lines: string[] = [`# ${conversation.title}`, ''] | |
| 31 | + lines.push(`*Model: ${conversation.modelID} (${conversation.provider})*`, '') | |
| 32 | + if (conversation.systemPrompt) { | |
| 33 | + lines.push(`> **System:** ${conversation.systemPrompt}`, '') | |
| 34 | + } | |
| 35 | + for (const message of conversation.messages) { | |
| 36 | + const label = message.role === 'user' ? '**You**' : `**Assistant** (${message.modelID ?? ''})` | |
| 37 | + lines.push(`${label}:`, '', message.text, '') | |
| 38 | + if (message.citations && message.citations.length > 0) { | |
| 39 | + lines.push( | |
| 40 | + ...message.citations.map((c) => `[${c.index}]: ${c.url}${c.title ? ` "${c.title}"` : ''}`), | |
| 41 | + '' | |
| 42 | + ) | |
| 43 | + } | |
| 44 | + } | |
| 45 | + download(`${safeName(conversation.title)}.md`, lines.join('\n'), 'text/markdown') | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function exportConversationJSON(conversation: Conversation): void { | |
| 49 | + download( | |
| 50 | + `${safeName(conversation.title)}.json`, | |
| 51 | + JSON.stringify(conversation, null, 2), | |
| 52 | + 'application/json' | |
| 53 | + ) | |
| 54 | +} | |
| 55 | + | |
| 56 | +export async function downloadBackup(includeKeys: boolean): Promise<void> { | |
| 57 | + const data = await exportAll(includeKeys) | |
| 58 | + const stamp = new Date().toISOString().slice(0, 10) | |
| 59 | + download(`zyquo-cloud-backup-${stamp}.json`, JSON.stringify(data, null, 2), 'application/json') | |
| 60 | +} | |
added
src/state/store.ts
+619 −0
@@ -0,0 +1,619 @@ | ||
| 1 | +/* | |
| 2 | + * store.ts | |
| 3 | + * Zyquo Cloud Web | |
| 4 | + * | |
| 5 | + * Author: Simon-Pierre Boucher | |
| 6 | + * Mail: contact@spboucher.ai | |
| 7 | + * | |
| 8 | + * The app store (Zustand): settings, keys status, conversations, streaming | |
| 9 | + * state, and UI state. All persistence goes through storage/; all networking | |
| 10 | + * through providers/. Conversation behaviors ported from the native | |
| 11 | + * ConversationStore (send/stream/stop/regenerate/edit-resend/auto-title), | |
| 12 | + * extended with web-only variants + branching. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +import { create } from 'zustand' | |
| 16 | +import { cheapestModel, defaultModel, findModel, pricedCost } from '../features/catalogHelpers' | |
| 17 | +import { clientFor } from '../providers/registry' | |
| 18 | +import { ProviderError } from '../providers/types' | |
| 19 | +import { | |
| 20 | + deleteConversation as dbDeleteConversation, | |
| 21 | + loadConversations, | |
| 22 | + saveConversation, | |
| 23 | +} from '../storage/conversations' | |
| 24 | +import { configuredProviders, getKey } from '../storage/keys' | |
| 25 | +import { loadSettings, saveSettings } from '../storage/settings' | |
| 26 | +import { isLocked } from '../storage/vault' | |
| 27 | +import type { | |
| 28 | + AIModel, | |
| 29 | + Attachment, | |
| 30 | + ChatParameters, | |
| 31 | + Conversation, | |
| 32 | + Message, | |
| 33 | + MessageVariant, | |
| 34 | + Provider, | |
| 35 | + Settings, | |
| 36 | +} from '../types' | |
| 37 | + | |
| 38 | +export function uid(): string { | |
| 39 | + return crypto.randomUUID() | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface Toast { | |
| 43 | + id: string | |
| 44 | + text: string | |
| 45 | + kind: 'info' | 'error' | 'success' | |
| 46 | + /** Optional undo action (soft deletes). */ | |
| 47 | + undo?: () => void | |
| 48 | +} | |
| 49 | + | |
| 50 | +interface StreamingState { | |
| 51 | + /** Message id currently being streamed into. */ | |
| 52 | + messageID: string | |
| 53 | + abort: AbortController | |
| 54 | + startedAt: number | |
| 55 | + outputChars: number | |
| 56 | +} | |
| 57 | + | |
| 58 | +export interface ZyquoState { | |
| 59 | + // Settings | |
| 60 | + settings: Settings | |
| 61 | + updateSettings: (patch: Partial<Settings>) => void | |
| 62 | + | |
| 63 | + // Keys (presence only — values read from storage on demand) | |
| 64 | + keyedProviders: Provider[] | |
| 65 | + refreshKeyedProviders: () => void | |
| 66 | + vaultLocked: boolean | |
| 67 | + setVaultLocked: (locked: boolean) => void | |
| 68 | + | |
| 69 | + // Conversations | |
| 70 | + conversations: Conversation[] | |
| 71 | + selectedID: string | null | |
| 72 | + searchText: string | |
| 73 | + loaded: boolean | |
| 74 | + loadAll: () => Promise<void> | |
| 75 | + select: (id: string | null) => void | |
| 76 | + setSearchText: (text: string) => void | |
| 77 | + newConversation: (opts?: { | |
| 78 | + model?: AIModel | |
| 79 | + systemPrompt?: string | |
| 80 | + personaID?: string | |
| 81 | + parameters?: ChatParameters | |
| 82 | + }) => Conversation | |
| 83 | + updateConversation: (id: string, patch: Partial<Conversation>) => void | |
| 84 | + removeConversation: (id: string) => void | |
| 85 | + restoreConversation: (conversation: Conversation) => void | |
| 86 | + | |
| 87 | + // Chat actions | |
| 88 | + send: (conversationID: string, text: string, attachments: Attachment[], overrideModel?: AIModel) => Promise<void> | |
| 89 | + regenerate: (conversationID: string, messageID: string, overrideModel?: AIModel) => Promise<void> | |
| 90 | + editAndResend: (conversationID: string, messageID: string, newText: string) => Promise<void> | |
| 91 | + deleteMessage: (conversationID: string, messageID: string) => void | |
| 92 | + stop: (conversationID: string) => void | |
| 93 | + continueGeneration: (conversationID: string) => Promise<void> | |
| 94 | + branchFrom: (conversationID: string, messageID: string) => Conversation | null | |
| 95 | + setActiveVariant: (conversationID: string, messageID: string, index: number) => void | |
| 96 | + | |
| 97 | + // Streaming | |
| 98 | + streaming: Record<string, StreamingState> | |
| 99 | + | |
| 100 | + // UI | |
| 101 | + /** Prompt text to preload into the next-opened conversation's input. */ | |
| 102 | + draftSeed: string | null | |
| 103 | + setDraftSeed: (seed: string | null) => void | |
| 104 | + sidebarOpen: boolean | |
| 105 | + setSidebarOpen: (open: boolean) => void | |
| 106 | + modelMenuOpen: boolean | |
| 107 | + setModelMenuOpen: (open: boolean) => void | |
| 108 | + settingsOpen: boolean | |
| 109 | + settingsTab: string | |
| 110 | + openSettings: (tab?: string) => void | |
| 111 | + closeSettings: () => void | |
| 112 | + paletteOpen: boolean | |
| 113 | + setPaletteOpen: (open: boolean) => void | |
| 114 | + compareOpen: boolean | |
| 115 | + setCompareOpen: (open: boolean) => void | |
| 116 | + toasts: Toast[] | |
| 117 | + toast: (text: string, kind?: Toast['kind'], undo?: () => void) => void | |
| 118 | + dismissToast: (id: string) => void | |
| 119 | +} | |
| 120 | + | |
| 121 | +function applyThemeToDocument(settings: Settings): void { | |
| 122 | + const root = document.documentElement | |
| 123 | + const dark = | |
| 124 | + settings.theme === 'dark' || | |
| 125 | + (settings.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches) | |
| 126 | + if (dark) root.setAttribute('data-theme', 'dark') | |
| 127 | + else root.removeAttribute('data-theme') | |
| 128 | + if (settings.accent !== 'indigo') root.setAttribute('data-accent', settings.accent) | |
| 129 | + else root.removeAttribute('data-accent') | |
| 130 | + root.style.setProperty('--z-chat-font-size', `${settings.chatFontSize}px`) | |
| 131 | +} | |
| 132 | + | |
| 133 | +export const useStore = create<ZyquoState>((set, get) => { | |
| 134 | + const initialSettings = loadSettings() | |
| 135 | + applyThemeToDocument(initialSettings) | |
| 136 | + | |
| 137 | + const persist = (conversation: Conversation) => { | |
| 138 | + void saveConversation(conversation).catch(() => { | |
| 139 | + get().toast('Failed to save conversation locally', 'error') | |
| 140 | + }) | |
| 141 | + } | |
| 142 | + | |
| 143 | + const mutateConversation = ( | |
| 144 | + id: string, | |
| 145 | + mutate: (c: Conversation) => Conversation, | |
| 146 | + save = true | |
| 147 | + ) => { | |
| 148 | + set((state) => { | |
| 149 | + const conversations = state.conversations.map((c) => { | |
| 150 | + if (c.id !== id) return c | |
| 151 | + const next = mutate(c) | |
| 152 | + if (save) persist(next) | |
| 153 | + return next | |
| 154 | + }) | |
| 155 | + return { conversations } | |
| 156 | + }) | |
| 157 | + } | |
| 158 | + | |
| 159 | + const modelFor = (conversation: Conversation): AIModel | undefined => | |
| 160 | + findModel(conversation.provider, conversation.modelID) | |
| 161 | + | |
| 162 | + /** Streams a reply into a fresh assistant message appended to the conversation. */ | |
| 163 | + const generateReply = async (conversationID: string, overrideModel?: AIModel) => { | |
| 164 | + const state = get() | |
| 165 | + const conversation = state.conversations.find((c) => c.id === conversationID) | |
| 166 | + if (!conversation) return | |
| 167 | + if (state.streaming[conversationID]) state.stop(conversationID) | |
| 168 | + | |
| 169 | + const model = overrideModel ?? modelFor(conversation) | |
| 170 | + if (!model) { | |
| 171 | + state.toast(`Model ${conversation.modelID} not found in catalog`, 'error') | |
| 172 | + return | |
| 173 | + } | |
| 174 | + const apiKey = getKey(model.provider) | |
| 175 | + if (!apiKey) { | |
| 176 | + state.toast(ProviderError.missingAPIKey(model.provider).message, 'error') | |
| 177 | + get().openSettings('providers') | |
| 178 | + return | |
| 179 | + } | |
| 180 | + if (overrideModel) { | |
| 181 | + mutateConversation(conversationID, (c) => ({ | |
| 182 | + ...c, | |
| 183 | + modelID: overrideModel.id, | |
| 184 | + provider: overrideModel.provider, | |
| 185 | + })) | |
| 186 | + } | |
| 187 | + | |
| 188 | + const placeholder: Message = { | |
| 189 | + id: uid(), | |
| 190 | + role: 'assistant', | |
| 191 | + text: '', | |
| 192 | + modelID: model.id, | |
| 193 | + provider: model.provider, | |
| 194 | + createdAt: Date.now(), | |
| 195 | + } | |
| 196 | + mutateConversation( | |
| 197 | + conversationID, | |
| 198 | + (c) => ({ ...c, messages: [...c.messages, placeholder], updatedAt: Date.now() }), | |
| 199 | + false | |
| 200 | + ) | |
| 201 | + | |
| 202 | + const abort = new AbortController() | |
| 203 | + set((s) => ({ | |
| 204 | + streaming: { | |
| 205 | + ...s.streaming, | |
| 206 | + [conversationID]: { | |
| 207 | + messageID: placeholder.id, | |
| 208 | + abort, | |
| 209 | + startedAt: Date.now(), | |
| 210 | + outputChars: 0, | |
| 211 | + }, | |
| 212 | + }, | |
| 213 | + })) | |
| 214 | + | |
| 215 | + const source = get().conversations.find((c) => c.id === conversationID) | |
| 216 | + if (!source) return | |
| 217 | + // Request context: all messages except the placeholder and errored turns. | |
| 218 | + const requestMessages = source.messages.filter( | |
| 219 | + (m) => m.id !== placeholder.id && !m.errorText | |
| 220 | + ) | |
| 221 | + const proxy = get().settings.proxyBaseURLs[model.provider] | |
| 222 | + | |
| 223 | + const patchMessage = (patch: (m: Message) => Message, save = false) => { | |
| 224 | + mutateConversation( | |
| 225 | + conversationID, | |
| 226 | + (c) => ({ | |
| 227 | + ...c, | |
| 228 | + messages: c.messages.map((m) => (m.id === placeholder.id ? patch(m) : m)), | |
| 229 | + }), | |
| 230 | + save | |
| 231 | + ) | |
| 232 | + } | |
| 233 | + | |
| 234 | + try { | |
| 235 | + const client = clientFor(model) | |
| 236 | + const events = client.streamChat( | |
| 237 | + { | |
| 238 | + model, | |
| 239 | + ...(source.systemPrompt ? { systemPrompt: source.systemPrompt } : {}), | |
| 240 | + messages: requestMessages, | |
| 241 | + parameters: source.parameters, | |
| 242 | + stream: true, | |
| 243 | + ...(proxy ? { baseURLOverride: proxy } : {}), | |
| 244 | + ...(model.customBaseURL ? { baseURLOverride: model.customBaseURL } : {}), | |
| 245 | + }, | |
| 246 | + apiKey, | |
| 247 | + abort.signal | |
| 248 | + ) | |
| 249 | + for await (const event of events) { | |
| 250 | + switch (event.type) { | |
| 251 | + case 'textDelta': | |
| 252 | + set((s) => { | |
| 253 | + const st = s.streaming[conversationID] | |
| 254 | + return st | |
| 255 | + ? { | |
| 256 | + streaming: { | |
| 257 | + ...s.streaming, | |
| 258 | + [conversationID]: { ...st, outputChars: st.outputChars + event.text.length }, | |
| 259 | + }, | |
| 260 | + } | |
| 261 | + : {} | |
| 262 | + }) | |
| 263 | + patchMessage((m) => ({ ...m, text: m.text + event.text })) | |
| 264 | + break | |
| 265 | + case 'reasoningDelta': | |
| 266 | + patchMessage((m) => ({ ...m, reasoning: (m.reasoning ?? '') + event.text })) | |
| 267 | + break | |
| 268 | + case 'citations': | |
| 269 | + patchMessage((m) => ({ ...m, citations: event.citations })) | |
| 270 | + break | |
| 271 | + case 'usage': | |
| 272 | + patchMessage((m) => ({ | |
| 273 | + ...m, | |
| 274 | + usage: event.usage, | |
| 275 | + ...(model.pricing | |
| 276 | + ? { estimatedCost: pricedCost(model, event.usage) } | |
| 277 | + : {}), | |
| 278 | + })) | |
| 279 | + break | |
| 280 | + case 'finished': | |
| 281 | + break | |
| 282 | + } | |
| 283 | + } | |
| 284 | + patchMessage((m) => m, true) // final persist | |
| 285 | + void autoTitleIfNeeded(conversationID) | |
| 286 | + } catch (err) { | |
| 287 | + const cancelled = err instanceof ProviderError && err.kind === 'cancelled' | |
| 288 | + if (!cancelled) { | |
| 289 | + const text = err instanceof Error ? err.message : String(err) | |
| 290 | + patchMessage((m) => ({ ...m, errorText: text }), true) | |
| 291 | + get().toast(text, 'error') | |
| 292 | + } else { | |
| 293 | + patchMessage((m) => m, true) | |
| 294 | + } | |
| 295 | + } finally { | |
| 296 | + set((s) => { | |
| 297 | + const streaming = { ...s.streaming } | |
| 298 | + delete streaming[conversationID] | |
| 299 | + return { streaming } | |
| 300 | + }) | |
| 301 | + } | |
| 302 | + } | |
| 303 | + | |
| 304 | + /** Native auto-title behavior: after the first completed exchange. */ | |
| 305 | + const autoTitleIfNeeded = async (conversationID: string) => { | |
| 306 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 307 | + if (!conversation || !conversation.hasAutoTitle) return | |
| 308 | + const assistantTurns = conversation.messages.filter( | |
| 309 | + (m) => m.role === 'assistant' && m.text.trim() !== '' | |
| 310 | + ) | |
| 311 | + if (assistantTurns.length !== 1) return | |
| 312 | + const model = cheapestModel(conversation.provider) | |
| 313 | + const apiKey = model ? getKey(model.provider) : undefined | |
| 314 | + if (!model || !apiKey) return | |
| 315 | + const firstUser = conversation.messages.find((m) => m.role === 'user')?.text ?? '' | |
| 316 | + const lastAssistant = assistantTurns[assistantTurns.length - 1]?.text ?? '' | |
| 317 | + try { | |
| 318 | + const result = await clientFor(model).complete( | |
| 319 | + { | |
| 320 | + model, | |
| 321 | + messages: [ | |
| 322 | + { | |
| 323 | + id: uid(), | |
| 324 | + role: 'user', | |
| 325 | + text: | |
| 326 | + 'Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.\n' + | |
| 327 | + `User: ${firstUser.slice(0, 500)}\nAssistant: ${lastAssistant.slice(0, 500)}`, | |
| 328 | + createdAt: Date.now(), | |
| 329 | + }, | |
| 330 | + ], | |
| 331 | + parameters: { maxTokens: 24 }, | |
| 332 | + stream: false, | |
| 333 | + }, | |
| 334 | + apiKey | |
| 335 | + ) | |
| 336 | + const title = result.text | |
| 337 | + .trim() | |
| 338 | + .replace(/^["'“”]+|["'“”]+$/g, '') | |
| 339 | + .slice(0, 60) | |
| 340 | + const current = get().conversations.find((c) => c.id === conversationID) | |
| 341 | + if (title !== '' && current?.hasAutoTitle) { | |
| 342 | + mutateConversation(conversationID, (c) => ({ ...c, title })) | |
| 343 | + } | |
| 344 | + } catch { | |
| 345 | + // Silent failure — title stays "New Chat". | |
| 346 | + } | |
| 347 | + } | |
| 348 | + | |
| 349 | + return { | |
| 350 | + settings: initialSettings, | |
| 351 | + updateSettings: (patch) => { | |
| 352 | + const settings = { ...get().settings, ...patch } | |
| 353 | + saveSettings(settings) | |
| 354 | + applyThemeToDocument(settings) | |
| 355 | + set({ settings }) | |
| 356 | + }, | |
| 357 | + | |
| 358 | + keyedProviders: [], | |
| 359 | + refreshKeyedProviders: () => set({ keyedProviders: configuredProviders() }), | |
| 360 | + vaultLocked: isLocked(), | |
| 361 | + setVaultLocked: (locked) => set({ vaultLocked: locked }), | |
| 362 | + | |
| 363 | + conversations: [], | |
| 364 | + selectedID: null, | |
| 365 | + searchText: '', | |
| 366 | + loaded: false, | |
| 367 | + loadAll: async () => { | |
| 368 | + const conversations = await loadConversations() | |
| 369 | + set({ | |
| 370 | + conversations, | |
| 371 | + loaded: true, | |
| 372 | + selectedID: conversations[0]?.id ?? null, | |
| 373 | + keyedProviders: configuredProviders(), | |
| 374 | + }) | |
| 375 | + }, | |
| 376 | + select: (id) => set({ selectedID: id, sidebarOpen: false }), | |
| 377 | + setSearchText: (text) => set({ searchText: text }), | |
| 378 | + | |
| 379 | + newConversation: (opts = {}) => { | |
| 380 | + const settings = get().settings | |
| 381 | + const model = | |
| 382 | + opts.model ?? | |
| 383 | + findModel(settings.defaultProvider, settings.defaultModelID) ?? | |
| 384 | + defaultModel() | |
| 385 | + const systemPrompt = opts.systemPrompt ?? settings.defaultSystemPrompt | |
| 386 | + const conversation: Conversation = { | |
| 387 | + id: uid(), | |
| 388 | + title: 'New Chat', | |
| 389 | + createdAt: Date.now(), | |
| 390 | + updatedAt: Date.now(), | |
| 391 | + modelID: model.id, | |
| 392 | + provider: model.provider, | |
| 393 | + ...(systemPrompt !== '' ? { systemPrompt } : {}), | |
| 394 | + parameters: opts.parameters ?? { ...settings.defaultParameters }, | |
| 395 | + messages: [], | |
| 396 | + pinned: false, | |
| 397 | + hasAutoTitle: true, | |
| 398 | + ...(opts.personaID ? { personaID: opts.personaID } : {}), | |
| 399 | + } | |
| 400 | + set((s) => ({ | |
| 401 | + conversations: [conversation, ...s.conversations], | |
| 402 | + selectedID: conversation.id, | |
| 403 | + sidebarOpen: false, | |
| 404 | + })) | |
| 405 | + persist(conversation) | |
| 406 | + return conversation | |
| 407 | + }, | |
| 408 | + | |
| 409 | + updateConversation: (id, patch) => { | |
| 410 | + mutateConversation(id, (c) => ({ ...c, ...patch, updatedAt: Date.now() })) | |
| 411 | + }, | |
| 412 | + | |
| 413 | + removeConversation: (id) => { | |
| 414 | + const conversation = get().conversations.find((c) => c.id === id) | |
| 415 | + get().stop(id) | |
| 416 | + set((s) => { | |
| 417 | + const conversations = s.conversations.filter((c) => c.id !== id) | |
| 418 | + return { | |
| 419 | + conversations, | |
| 420 | + selectedID: s.selectedID === id ? (conversations[0]?.id ?? null) : s.selectedID, | |
| 421 | + } | |
| 422 | + }) | |
| 423 | + void dbDeleteConversation(id) | |
| 424 | + if (conversation) { | |
| 425 | + get().toast(`Deleted “${conversation.title}”`, 'info', () => | |
| 426 | + get().restoreConversation(conversation) | |
| 427 | + ) | |
| 428 | + } | |
| 429 | + }, | |
| 430 | + | |
| 431 | + restoreConversation: (conversation) => { | |
| 432 | + set((s) => ({ | |
| 433 | + conversations: [conversation, ...s.conversations].sort( | |
| 434 | + (a, b) => b.updatedAt - a.updatedAt | |
| 435 | + ), | |
| 436 | + selectedID: conversation.id, | |
| 437 | + })) | |
| 438 | + persist(conversation) | |
| 439 | + }, | |
| 440 | + | |
| 441 | + send: async (conversationID, text, attachments, overrideModel) => { | |
| 442 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 443 | + if (!conversation) return | |
| 444 | + const model = overrideModel ?? findModel(conversation.provider, conversation.modelID) | |
| 445 | + const userMessage: Message = { | |
| 446 | + id: uid(), | |
| 447 | + role: 'user', | |
| 448 | + text, | |
| 449 | + ...(attachments.length > 0 ? { attachments } : {}), | |
| 450 | + ...(model ? { modelID: model.id, provider: model.provider } : {}), | |
| 451 | + createdAt: Date.now(), | |
| 452 | + } | |
| 453 | + mutateConversation(conversationID, (c) => ({ | |
| 454 | + ...c, | |
| 455 | + messages: [...c.messages, userMessage], | |
| 456 | + updatedAt: Date.now(), | |
| 457 | + })) | |
| 458 | + await generateReply(conversationID, overrideModel) | |
| 459 | + }, | |
| 460 | + | |
| 461 | + regenerate: async (conversationID, messageID, overrideModel) => { | |
| 462 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 463 | + if (!conversation) return | |
| 464 | + const index = conversation.messages.findIndex((m) => m.id === messageID) | |
| 465 | + const message = conversation.messages[index] | |
| 466 | + if (!message || message.role !== 'assistant') return | |
| 467 | + // Web improvement over native: keep the old answer as a variant instead | |
| 468 | + // of dropping it, unless it was empty/errored. | |
| 469 | + const keepVariant = message.text.trim() !== '' && !message.errorText | |
| 470 | + if (index === conversation.messages.length - 1) { | |
| 471 | + mutateConversation(conversationID, (c) => { | |
| 472 | + const messages = c.messages.slice(0, -1) | |
| 473 | + return { ...c, messages } | |
| 474 | + }) | |
| 475 | + const old: MessageVariant | null = keepVariant | |
| 476 | + ? { | |
| 477 | + text: message.text, | |
| 478 | + ...(message.reasoning ? { reasoning: message.reasoning } : {}), | |
| 479 | + ...(message.citations ? { citations: message.citations } : {}), | |
| 480 | + ...(message.modelID ? { modelID: message.modelID } : {}), | |
| 481 | + ...(message.provider ? { provider: message.provider } : {}), | |
| 482 | + ...(message.usage ? { usage: message.usage } : {}), | |
| 483 | + ...(message.estimatedCost !== undefined | |
| 484 | + ? { estimatedCost: message.estimatedCost } | |
| 485 | + : {}), | |
| 486 | + createdAt: message.createdAt, | |
| 487 | + } | |
| 488 | + : null | |
| 489 | + await generateReply(conversationID, overrideModel) | |
| 490 | + if (old) { | |
| 491 | + // Attach the previous answer as a variant of the fresh message. | |
| 492 | + const fresh = get().conversations.find((c) => c.id === conversationID) | |
| 493 | + const last = fresh?.messages[fresh.messages.length - 1] | |
| 494 | + if (last && last.role === 'assistant') { | |
| 495 | + mutateConversation(conversationID, (c) => ({ | |
| 496 | + ...c, | |
| 497 | + messages: c.messages.map((m) => | |
| 498 | + m.id === last.id | |
| 499 | + ? { | |
| 500 | + ...m, | |
| 501 | + variants: [...(message.variants ?? []), old], | |
| 502 | + } | |
| 503 | + : m | |
| 504 | + ), | |
| 505 | + })) | |
| 506 | + } | |
| 507 | + } | |
| 508 | + } | |
| 509 | + }, | |
| 510 | + | |
| 511 | + editAndResend: async (conversationID, messageID, newText) => { | |
| 512 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 513 | + if (!conversation) return | |
| 514 | + const index = conversation.messages.findIndex((m) => m.id === messageID) | |
| 515 | + if (index === -1) return | |
| 516 | + mutateConversation(conversationID, (c) => ({ | |
| 517 | + ...c, | |
| 518 | + messages: c.messages | |
| 519 | + .slice(0, index + 1) | |
| 520 | + .map((m) => (m.id === messageID ? { ...m, text: newText } : m)), | |
| 521 | + updatedAt: Date.now(), | |
| 522 | + })) | |
| 523 | + await generateReply(conversationID) | |
| 524 | + }, | |
| 525 | + | |
| 526 | + deleteMessage: (conversationID, messageID) => { | |
| 527 | + mutateConversation(conversationID, (c) => ({ | |
| 528 | + ...c, | |
| 529 | + messages: c.messages.filter((m) => m.id !== messageID), | |
| 530 | + updatedAt: Date.now(), | |
| 531 | + })) | |
| 532 | + }, | |
| 533 | + | |
| 534 | + stop: (conversationID) => { | |
| 535 | + const st = get().streaming[conversationID] | |
| 536 | + if (st) st.abort.abort() | |
| 537 | + }, | |
| 538 | + | |
| 539 | + continueGeneration: async (conversationID) => { | |
| 540 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 541 | + if (!conversation) return | |
| 542 | + const continueMessage: Message = { | |
| 543 | + id: uid(), | |
| 544 | + role: 'user', | |
| 545 | + text: 'Continue exactly where you left off.', | |
| 546 | + createdAt: Date.now(), | |
| 547 | + } | |
| 548 | + mutateConversation(conversationID, (c) => ({ | |
| 549 | + ...c, | |
| 550 | + messages: [...c.messages, continueMessage], | |
| 551 | + updatedAt: Date.now(), | |
| 552 | + })) | |
| 553 | + await generateReply(conversationID) | |
| 554 | + }, | |
| 555 | + | |
| 556 | + branchFrom: (conversationID, messageID) => { | |
| 557 | + const conversation = get().conversations.find((c) => c.id === conversationID) | |
| 558 | + if (!conversation) return null | |
| 559 | + const index = conversation.messages.findIndex((m) => m.id === messageID) | |
| 560 | + if (index === -1) return null | |
| 561 | + const branch: Conversation = { | |
| 562 | + ...conversation, | |
| 563 | + id: uid(), | |
| 564 | + title: `${conversation.title} (branch)`, | |
| 565 | + createdAt: Date.now(), | |
| 566 | + updatedAt: Date.now(), | |
| 567 | + messages: conversation.messages.slice(0, index + 1).map((m) => ({ ...m, id: uid() })), | |
| 568 | + pinned: false, | |
| 569 | + hasAutoTitle: false, | |
| 570 | + branchedFrom: { conversationID, messageID }, | |
| 571 | + } | |
| 572 | + set((s) => ({ | |
| 573 | + conversations: [branch, ...s.conversations], | |
| 574 | + selectedID: branch.id, | |
| 575 | + })) | |
| 576 | + persist(branch) | |
| 577 | + return branch | |
| 578 | + }, | |
| 579 | + | |
| 580 | + setActiveVariant: (conversationID, messageID, index) => { | |
| 581 | + mutateConversation(conversationID, (c) => ({ | |
| 582 | + ...c, | |
| 583 | + messages: c.messages.map((m) => { | |
| 584 | + if (m.id !== messageID || !m.variants) return m | |
| 585 | + const variants = m.variants | |
| 586 | + const target = index === -1 ? null : variants[index] | |
| 587 | + if (index === -1 || !target) return { ...m, activeVariant: undefined } | |
| 588 | + // Swap: current content goes into the variant slot, variant becomes active. | |
| 589 | + return { ...m, activeVariant: index } | |
| 590 | + }), | |
| 591 | + })) | |
| 592 | + }, | |
| 593 | + | |
| 594 | + streaming: {}, | |
| 595 | + | |
| 596 | + draftSeed: null, | |
| 597 | + setDraftSeed: (seed) => set({ draftSeed: seed }), | |
| 598 | + sidebarOpen: false, | |
| 599 | + setSidebarOpen: (open) => set({ sidebarOpen: open }), | |
| 600 | + modelMenuOpen: false, | |
| 601 | + setModelMenuOpen: (open) => set({ modelMenuOpen: open }), | |
| 602 | + settingsOpen: false, | |
| 603 | + settingsTab: 'providers', | |
| 604 | + openSettings: (tab = 'providers') => set({ settingsOpen: true, settingsTab: tab }), | |
| 605 | + closeSettings: () => set({ settingsOpen: false }), | |
| 606 | + paletteOpen: false, | |
| 607 | + setPaletteOpen: (open) => set({ paletteOpen: open }), | |
| 608 | + compareOpen: false, | |
| 609 | + setCompareOpen: (open) => set({ compareOpen: open }), | |
| 610 | + | |
| 611 | + toasts: [], | |
| 612 | + toast: (text, kind = 'info', undo) => { | |
| 613 | + const id = uid() | |
| 614 | + set((s) => ({ toasts: [...s.toasts, { id, text, kind, ...(undo ? { undo } : {}) }] })) | |
| 615 | + setTimeout(() => get().dismissToast(id), 6000) | |
| 616 | + }, | |
| 617 | + dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), | |
| 618 | + } | |
| 619 | +}) | |
modified
tsconfig.app.tsbuildinfo
+1 −1
@@ -1 +1 @@ | ||
| 1 | −{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/features/devharness.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"} | |
| \ No newline at end of file | ||
| 1 | +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatview.tsx","./src/components/firstrun.tsx","./src/components/inputbar.tsx","./src/components/markdown.tsx","./src/components/messagebubble.tsx","./src/components/modelmenu.tsx","./src/components/paramspanel.tsx","./src/components/settingsmodal.tsx","./src/components/sidebar.tsx","./src/components/suggestiongrid.tsx","./src/components/toasts.tsx","./src/components/zyquoglyph.tsx","./src/components/icons.tsx","./src/features/streamtestview.tsx","./src/features/cataloghelpers.ts","./src/features/devharness.ts","./src/features/exporters.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/state/store.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"} | |
| \ No newline at end of file | ||
| 2 | 2 | |