Compare commits

..

9 Commits

Author SHA1 Message Date
a7278c801a Refactor Hubert chat input and improve message persistence
- Create dedicated HubertInput component with reliable auto-resize
- Save all messages automatically to D1 database
- Add visitor name saving via model tool call
- Add migration for visitor name column
- Fix conversation creation in new-visitor endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:53:55 -07:00
e46306389e Remove OPENROUTER_API_KEY from vars (already configured as secret)
Fixes deployment error caused by binding name conflict. The API key
is properly configured as a Cloudflare Pages secret.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:28:18 -07:00
269286763e Add D1 database ID for hubert-conversations
Created D1 database and updated wrangler.jsonc with actual database UUID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:25:12 -07:00
1345012a6a Refactor navigation to floating left icon bar on desktop
- Replace horizontal top nav with vertical icon-only sidebar on desktop (lg+)
- Add hover tooltips showing page labels
- Active page highlighted with accent color background
- Theme toggle integrated at bottom of nav bar
- Mobile retains hamburger menu with full-screen overlay
- Fix mobile menu script for Astro view transitions
- Reduce hero/layout top padding since desktop nav no longer occupies header space
- Add left padding to main content to accommodate fixed left nav

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:08:18 -07:00
64855131ba Redesign page heroes, add pill buttons, improve HubertChat
Hero sections:
- Redesign blog, dev, and contact page heroes with sleek modern style
- Add gradient text titles, floating accent orbs, stats rows
- Remove heavy terminal-style elements for cleaner aesthetic

Buttons:
- Add rounded-full to all buttons sitewide for consistent pill shape
- Update btn-primary and btn-ghost utilities in global.css

HubertChat:
- Move Hubert to dedicated /hubert page
- Add framer-motion for smooth input transitions
- Support markdown rendering with marked + DOMPurify
- Add multiline textarea with Shift+Enter support
- Fix light mode visibility for input and message bubbles
- Extract useHubertChat hook for cleaner state management

Fonts:
- Update to Sora (sans) and IBM Plex Mono (mono)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 03:49:26 -07:00
ffbb86b2d9 notepad: sudo pacman -Syu 2026-01-13 22:24:52 -07:00
95ff689ace Remove debugging logs from Hubert chat component and API endpoints
- Stripped console.log statements from client and server

Hubert The Eunuch
2026-01-03 20:09:31 -07:00
9ef1027cc6 Add permissions file, enhance HubertChat init & retry, update API routes
- Introduce .claude/.fuse_hidden... with explicit allow permissions
- Add fetchWithTimeout utility for robust network calls
- Implement init error handling and retry mechanism in HubChat
- Update API handlers for new visitor and chat routes
- Adjust wrangler.jsonc for deployment changes

Signed: Hubert The Eunuch
2026-01-03 18:18:53 -07:00
d4959500a8 Add LangChain support, new UI components, and schema migration
- Add @langchain packages and zod for schema validation
- Introduce HubertChat component and Astro sections
- Create initial DB migration for Hubert data
- Update API routes for chat, conversations, and new visitors
- Adjust package.json and pnpm-lock with new dependencies

Hubert The Eunuch
2026-01-03 17:43:12 -07:00
31 changed files with 1916 additions and 231 deletions

View File

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"WebFetch(domain:openrouter.ai)",
"Bash(node:*)",
"Bash(curl:*)",
"Bash(pnpm build:*)",
"Bash(find:*)",
"Bash(pnpm add:*)",
"WebFetch(domain:substance.biohazardvfx.com)",
"WebFetch(domain:substrate.biohazardvfx.com)"
],
"deny": [],
"ask": []
}
}

View File

@ -1,9 +0,0 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-03T03:44:54.441Z"
session_id: "ses_47e0ab7d0ffeVYIumRnRO0IG1n"
---
Complete the task as instructed

View File

@ -22,19 +22,27 @@
"@astrojs/react": "^4.4.2", "@astrojs/react": "^4.4.2",
"@astrojs/rss": "^4.0.14", "@astrojs/rss": "^4.0.14",
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.6.0",
"@langchain/cloudflare": "^1.0.1",
"@langchain/core": "^1.1.8",
"@langchain/langgraph": "^1.0.7",
"@langchain/openai": "^1.2.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"astro": "^5.16.4", "astro": "^5.16.4",
"dompurify": "^3.3.1",
"framer-motion": "^12.26.2",
"lunr": "^2.3.9", "lunr": "^2.3.9",
"marked": "^17.0.1", "marked": "^17.0.1",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17",
"zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"wrangler": "^4.53.0" "wrangler": "^4.53.0"
} }

386
pnpm-lock.yaml generated
View File

@ -23,6 +23,18 @@ importers:
'@astrojs/sitemap': '@astrojs/sitemap':
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
'@langchain/cloudflare':
specifier: ^1.0.1
version: 1.0.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))
'@langchain/core':
specifier: ^1.1.8
version: 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
'@langchain/langgraph':
specifier: ^1.0.7
version: 1.0.7(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.0(zod@4.3.4))(zod@4.3.4)
'@langchain/openai':
specifier: ^1.2.0
version: 1.2.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(ws@8.18.0)
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.19 specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.17) version: 0.5.19(tailwindcss@4.1.17)
@ -38,6 +50,12 @@ importers:
astro: astro:
specifier: ^5.16.4 specifier: ^5.16.4
version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3) version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)
dompurify:
specifier: ^3.3.1
version: 3.3.1
framer-motion:
specifier: ^12.26.2
version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
lunr: lunr:
specifier: ^2.3.9 specifier: ^2.3.9
version: 2.3.9 version: 2.3.9
@ -56,7 +74,13 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.17 specifier: ^4.1.17
version: 4.1.17 version: 4.1.17
zod:
specifier: ^4.3.4
version: 4.3.4
devDependencies: devDependencies:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.10.1 version: 24.10.1
@ -199,6 +223,9 @@ packages:
resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==} resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@cloudflare/kv-asset-handler@0.4.0': '@cloudflare/kv-asset-handler@0.4.0':
resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -1018,6 +1045,53 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@langchain/cloudflare@1.0.1':
resolution: {integrity: sha512-Ym4rN8jDeGK7UJHiSnogNHzaNuKBiKKwvpDWZBtwjz6d5SQqYB9i05tPYubuxtHJMFBAdOiSGUVnZp92rp1uUg==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.0.0
'@langchain/core@1.1.8':
resolution: {integrity: sha512-kIUidOgc0ZdyXo4Ahn9Zas+OayqOfk4ZoKPi7XaDipNSWSApc2+QK5BVcjvwtzxstsNOrmXJiJWEN6WPF/MvAw==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint@1.0.0':
resolution: {integrity: sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.0.1
'@langchain/langgraph-sdk@1.3.1':
resolution: {integrity: sha512-zTi7DZHwqtMEzapvm3I1FL4Q7OZsxtq9tTXy6s2gcCxyIU3sphqRboqytqVN7dNHLdTCLb8nXy49QKurs2MIBg==}
peerDependencies:
'@langchain/core': ^1.0.1
react: ^18 || ^19
react-dom: ^18 || ^19
peerDependenciesMeta:
'@langchain/core':
optional: true
react:
optional: true
react-dom:
optional: true
'@langchain/langgraph@1.0.7':
resolution: {integrity: sha512-EBGqNOWoRiEoLUaeuiXRpUM8/DE6QcwiirNyd97XhezStebBoTTilWH8CUt6S94JRGl5zwfBBRHfzotDnZS/eA==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.0.1
zod: ^3.25.32 || ^4.1.0
zod-to-json-schema: ^3.x
peerDependenciesMeta:
zod-to-json-schema:
optional: true
'@langchain/openai@1.2.0':
resolution: {integrity: sha512-r2g5Be3Sygw7VTJ89WVM/M94RzYToNTwXf8me1v+kgKxzdHbd/8XPYDFxpXEp3REyPgUrtJs+Oplba9pkTH5ug==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.0.0
'@mdx-js/mdx@3.1.1': '@mdx-js/mdx@3.1.1':
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
@ -1296,6 +1370,10 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -1334,15 +1412,24 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/sax@1.2.7': '@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@ -1382,6 +1469,14 @@ packages:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.3: ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1447,6 +1542,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
camelcase@8.0.0: camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -1457,6 +1556,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2: chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@ -1520,6 +1623,9 @@ packages:
common-ancestor-path@1.0.1: common-ancestor-path@1.0.1:
resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==}
console-table-printer@2.15.0:
resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -1569,6 +1675,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@ -1616,6 +1726,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2: domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@ -1703,6 +1816,9 @@ packages:
estree-walker@3.0.3: estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.1: eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -1739,6 +1855,20 @@ packages:
fontkit@2.0.4: fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
framer-motion@12.26.2:
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1764,6 +1894,10 @@ packages:
h3@1.15.4: h3@1.15.4:
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-from-html@2.0.3: hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@ -1859,6 +1993,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1884,6 +2021,23 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
langsmith@0.4.4:
resolution: {integrity: sha512-rpLzrklyL7fIP/8wwrSv2tKDwMJTvkhgWeKxDvmbAB2n/p5FzqujEWCpA//u9hnrdmXZc1dCJZ+iqN6KgaEoEA==}
peerDependencies:
'@opentelemetry/api': '*'
'@opentelemetry/exporter-trace-otlp-proto': '*'
'@opentelemetry/sdk-trace-base': '*'
openai: '*'
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@opentelemetry/exporter-trace-otlp-proto':
optional: true
'@opentelemetry/sdk-trace-base':
optional: true
openai:
optional: true
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -2161,6 +2315,12 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
motion-dom@12.26.2:
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
motion-utils@12.24.10:
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
mrmime@2.0.1: mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2168,6 +2328,10 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -2208,14 +2372,42 @@ packages:
oniguruma-to-es@4.3.4: oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
openai@6.15.0:
resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-limit@6.2.0: p-limit@6.2.0:
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-queue@8.1.1: p-queue@8.1.1:
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
p-retry@4.6.2:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-timeout@6.1.4: p-timeout@6.1.4:
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
@ -2366,6 +2558,10 @@ packages:
retext@9.0.0: retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
rollup@4.53.3: rollup@4.53.3:
resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -2400,6 +2596,9 @@ packages:
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
simple-wcswidth@1.1.2:
resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@ -2462,6 +2661,10 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'} engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
svgo@4.0.0: svgo@4.0.0:
resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -2645,6 +2848,14 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vfile-location@5.0.3: vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -2804,6 +3015,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.4:
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
zwitch@2.0.4: zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -3050,6 +3264,8 @@ snapshots:
dependencies: dependencies:
fontkit: 2.0.4 fontkit: 2.0.4
'@cfworker/json-schema@4.1.1': {}
'@cloudflare/kv-asset-handler@0.4.0': '@cloudflare/kv-asset-handler@0.4.0':
dependencies: dependencies:
mime: 3.0.0 mime: 3.0.0
@ -3537,6 +3753,66 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@langchain/cloudflare@1.0.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
uuid: 10.0.0
'@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))':
dependencies:
'@cfworker/json-schema': 4.1.1
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.21
langsmith: 0.4.4(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
mustache: 4.2.0
p-queue: 6.6.2
uuid: 10.0.0
zod: 4.3.4
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
'@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
uuid: 10.0.0
'@langchain/langgraph-sdk@1.3.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
p-queue: 6.6.2
p-retry: 4.6.2
uuid: 9.0.1
optionalDependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
'@langchain/langgraph@1.0.7(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.0(zod@4.3.4))(zod@4.3.4)':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
'@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))
'@langchain/langgraph-sdk': 1.3.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
uuid: 10.0.0
zod: 4.3.4
optionalDependencies:
zod-to-json-schema: 3.25.0(zod@4.3.4)
transitivePeerDependencies:
- react
- react-dom
'@langchain/openai@1.2.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(ws@8.18.0)':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
js-tiktoken: 1.0.21
openai: 6.15.0(ws@8.18.0)(zod@4.3.4)
zod: 4.3.4
transitivePeerDependencies:
- ws
'@mdx-js/mdx@3.1.1': '@mdx-js/mdx@3.1.1':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -3796,6 +4072,10 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -3836,14 +4116,21 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/retry@0.12.0': {}
'@types/sax@1.2.7': '@types/sax@1.2.7':
dependencies: dependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@types/uuid@10.0.0': {}
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))':
@ -3876,6 +4163,12 @@ snapshots:
ansi-regex@6.2.2: {} ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {} ansi-styles@6.2.3: {}
anymatch@3.1.3: anymatch@3.1.3:
@ -4032,12 +4325,19 @@ snapshots:
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.2(browserslist@4.28.1) update-browserslist-db: 1.2.2(browserslist@4.28.1)
camelcase@6.3.0: {}
camelcase@8.0.0: {} camelcase@8.0.0: {}
caniuse-lite@1.0.30001759: {} caniuse-lite@1.0.30001759: {}
ccount@2.0.1: {} ccount@2.0.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {} chalk@5.6.2: {}
character-entities-html4@2.1.0: {} character-entities-html4@2.1.0: {}
@ -4084,6 +4384,10 @@ snapshots:
common-ancestor-path@1.0.1: {} common-ancestor-path@1.0.1: {}
console-table-printer@2.15.0:
dependencies:
simple-wcswidth: 1.1.2
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-es@1.2.2: {} cookie-es@1.2.2: {}
@ -4126,6 +4430,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@ -4166,6 +4472,10 @@ snapshots:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2: domutils@3.2.2:
dependencies: dependencies:
dom-serializer: 2.0.0 dom-serializer: 2.0.0
@ -4332,6 +4642,8 @@ snapshots:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
exit-hook@2.2.1: {} exit-hook@2.2.1: {}
@ -4367,6 +4679,15 @@ snapshots:
unicode-properties: 1.4.1 unicode-properties: 1.4.1
unicode-trie: 2.0.0 unicode-trie: 2.0.0
framer-motion@12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
motion-dom: 12.26.2
motion-utils: 12.24.10
tslib: 2.8.1
optionalDependencies:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@ -4392,6 +4713,8 @@ snapshots:
ufo: 1.6.1 ufo: 1.6.1
uncrypto: 0.1.3 uncrypto: 0.1.3
has-flag@4.0.0: {}
hast-util-from-html@2.0.3: hast-util-from-html@2.0.3:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -4561,6 +4884,10 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@ -4575,6 +4902,17 @@ snapshots:
kleur@4.1.5: {} kleur@4.1.5: {}
langsmith@0.4.4(openai@6.15.0(ws@8.18.0)(zod@4.3.4)):
dependencies:
'@types/uuid': 10.0.0
chalk: 4.1.2
console-table-printer: 2.15.0
p-queue: 6.6.2
semver: 7.7.3
uuid: 10.0.0
optionalDependencies:
openai: 6.15.0(ws@8.18.0)(zod@4.3.4)
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true
@ -5125,10 +5463,18 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
motion-dom@12.26.2:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}
mustache@4.2.0: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
neotraverse@0.6.18: {} neotraverse@0.6.18: {}
@ -5165,15 +5511,36 @@ snapshots:
regex: 6.0.1 regex: 6.0.1
regex-recursion: 6.0.2 regex-recursion: 6.0.2
openai@6.15.0(ws@8.18.0)(zod@4.3.4):
optionalDependencies:
ws: 8.18.0
zod: 4.3.4
p-finally@1.0.0: {}
p-limit@6.2.0: p-limit@6.2.0:
dependencies: dependencies:
yocto-queue: 1.2.2 yocto-queue: 1.2.2
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-queue@8.1.1: p-queue@8.1.1:
dependencies: dependencies:
eventemitter3: 5.0.1 eventemitter3: 5.0.1
p-timeout: 6.1.4 p-timeout: 6.1.4
p-retry@4.6.2:
dependencies:
'@types/retry': 0.12.0
retry: 0.13.1
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
p-timeout@6.1.4: {} p-timeout@6.1.4: {}
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
@ -5395,6 +5762,8 @@ snapshots:
retext-stringify: 4.0.0 retext-stringify: 4.0.0
unified: 11.0.5 unified: 11.0.5
retry@0.13.1: {}
rollup@4.53.3: rollup@4.53.3:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -5503,6 +5872,8 @@ snapshots:
dependencies: dependencies:
is-arrayish: 0.3.4 is-arrayish: 0.3.4
simple-wcswidth@1.1.2: {}
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
sitemap@8.0.2: sitemap@8.0.2:
@ -5561,6 +5932,10 @@ snapshots:
supports-color@10.2.2: {} supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
svgo@4.0.0: svgo@4.0.0:
dependencies: dependencies:
commander: 11.1.0 commander: 11.1.0
@ -5703,6 +6078,10 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@9.0.1: {}
vfile-location@5.0.3: vfile-location@5.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@ -5833,6 +6212,11 @@ snapshots:
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76
zod-to-json-schema@3.25.0(zod@4.3.4):
dependencies:
zod: 4.3.4
optional: true
zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@ -5842,4 +6226,6 @@ snapshots:
zod@3.25.76: {} zod@3.25.76: {}
zod@4.3.4: {}
zwitch@2.0.4: {} zwitch@2.0.4: {}

View File

@ -124,14 +124,14 @@ const professionalServiceSchema = {
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
media="print" media="print"
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript> <noscript>
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</noscript> </noscript>

View File

@ -19,13 +19,13 @@ const today = new Date();
</h2> </h2>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300"> <a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 rounded-full">
<span class="font-mono text-xs font-bold uppercase tracking-widest">Connect_Uplink</span> <span class="font-mono text-xs font-bold uppercase tracking-widest">Connect_Uplink</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
<path d="M5 12h14M12 5l7 7-7 7"/> <path d="M5 12h14M12 5l7 7-7 7"/>
</svg> </svg>
</a> </a>
<a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300"> <a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 rounded-full">
<span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span> <span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span>
</a> </a>
</div> </div>

View File

@ -0,0 +1,267 @@
import React from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { useHubertChat } from '../hooks/useHubertChat';
import HubertInput from './HubertInput';
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
// Render markdown to sanitized HTML
function renderMarkdown(content: string): string {
const rawHtml = marked.parse(content, { async: false }) as string;
return DOMPurify.sanitize(rawHtml);
}
export default function HubertChat() {
const {
messages,
input,
isTyping,
isInitializing,
initError,
setInput,
sendMessage,
retryInit,
messagesEndRef,
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
// Initial/Loading state - centered branding with input
if (isInitializing && !initError) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
{/* Branding */}
<div className="flex items-center gap-3 mb-8">
<span className="text-2xl font-semibold text-[var(--theme-text-primary)]">Hubert</span>
<div className="w-4 h-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
</div>
<p className="text-sm text-[var(--theme-text-muted)]">Waking up...</p>
<span className="sr-only" role="status" aria-live="polite">
Loading Hubert chat interface
</span>
</div>
);
}
// Error state
if (initError) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
<span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
<p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
{initError}
</p>
<button
onClick={retryInit}
className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
aria-label="Retry connecting to Hubert"
>
Try again
</button>
</div>
);
}
// No messages yet - show centered input
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
{/* Branding */}
<span className="text-3xl font-semibold text-[var(--theme-text-primary)] mb-10">Hubert</span>
{/* Input bar */}
<div className="w-full max-w-2xl">
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="What do you want to know?"
/>
</div>
{/* Subtitle */}
<p className="text-xs text-[var(--theme-text-subtle)] mt-6">
A miserable AI assistant, here to interview you.
</p>
</div>
);
}
// Chat mode - messages with input at bottom
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Messages area - scrollable */}
<div
className="flex-1 overflow-y-auto px-4 py-6 min-h-0"
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, index) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'user' ? (
// User message - right aligned pill with markdown
<div className="max-w-[80%] bg-[var(--theme-bg-secondary)] rounded-3xl px-5 py-3">
<div
className="user-message text-[var(--theme-text-primary)] text-[15px] leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
/>
</div>
) : (
// Assistant message - left aligned with subtle label and markdown
<div className="max-w-[85%] space-y-1.5">
{(index === 0 || messages[index - 1]?.role === 'user') && (
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
Hubert
</span>
)}
<div
className="hubert-message text-[var(--theme-text-secondary)] text-[15px] leading-[1.7]"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
/>
</div>
)}
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex justify-start">
<div className="space-y-1.5">
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
Hubert
</span>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" />
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '300ms' }} />
</div>
</div>
<span className="sr-only" role="status">Hubert is typing</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input bar - pinned to bottom */}
<div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="max-w-3xl mx-auto">
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="How can Hubert help?"
/>
</div>
</div>
{/* Styles for markdown content */}
<style>{`
.hubert-message p,
.user-message p {
margin-bottom: 0.75rem;
}
.hubert-message p:last-child,
.user-message p:last-child {
margin-bottom: 0;
}
.hubert-message strong,
.user-message strong {
font-weight: 600;
}
.hubert-message strong {
color: var(--theme-text-primary);
}
.hubert-message em,
.user-message em {
font-style: italic;
}
.hubert-message code,
.user-message code {
background: var(--theme-bg-secondary);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.875em;
}
.user-message code {
background: rgba(255,255,255,0.1);
}
.hubert-message pre,
.user-message pre {
background: var(--theme-bg-secondary);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.75rem 0;
}
.user-message pre {
background: rgba(255,255,255,0.05);
}
.hubert-message pre code,
.user-message pre code {
background: none;
padding: 0;
}
.hubert-message ul, .hubert-message ol,
.user-message ul, .user-message ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.hubert-message li,
.user-message li {
margin-bottom: 0.25rem;
}
.hubert-message ul li,
.user-message ul li {
list-style-type: disc;
}
.hubert-message ol li,
.user-message ol li {
list-style-type: decimal;
}
.hubert-message blockquote,
.user-message blockquote {
border-left: 3px solid var(--color-brand-accent);
padding-left: 1rem;
margin: 0.75rem 0;
font-style: italic;
}
.hubert-message blockquote {
color: var(--theme-text-muted);
}
.hubert-message a,
.user-message a {
color: var(--color-brand-accent);
text-decoration: underline;
}
.hubert-message a:hover,
.user-message a:hover {
color: var(--theme-text-primary);
}
.hubert-message h1, .hubert-message h2, .hubert-message h3,
.user-message h1, .user-message h2, .user-message h3 {
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.hubert-message h1, .hubert-message h2, .hubert-message h3 {
color: var(--theme-text-primary);
}
.hubert-message h1, .user-message h1 { font-size: 1.25rem; }
.hubert-message h2, .user-message h2 { font-size: 1.125rem; }
.hubert-message h3, .user-message h3 { font-size: 1rem; }
`}</style>
</div>
);
}

View File

@ -0,0 +1,125 @@
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle, useState } from 'react';
interface HubertInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
disabled?: boolean;
placeholder?: string;
}
export interface HubertInputHandle {
focus: () => void;
}
/**
* Auto-resizing textarea input for Hubert chat.
* Uses a clean CSS-based approach with proper state management.
*/
const HubertInput = forwardRef<HubertInputHandle, HubertInputProps>(
({ value, onChange, onSubmit, disabled = false, placeholder = "Type a message..." }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isMultiline, setIsMultiline] = useState(false);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
}));
// Adjust textarea height based on content
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset to auto to get accurate scrollHeight
textarea.style.height = 'auto';
// Get the natural content height
const scrollHeight = textarea.scrollHeight;
// Clamp between min (44px) and max (200px)
const minHeight = 44;
const maxHeight = 200;
const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = `${newHeight}px`;
// Update multiline state (threshold at ~1.5 lines)
setIsMultiline(newHeight > 52);
}, []);
// Adjust height whenever value changes
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
// Also adjust on window resize
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && value.trim()) {
onSubmit();
}
}
};
return (
<div
className={`hubert-input-wrapper relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] transition-all duration-200 ease-out ${
isMultiline ? 'p-4 rounded-[28px]' : 'flex items-center px-6 py-2 rounded-full'
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
aria-label="Type your message"
rows={1}
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none overflow-hidden ${
isMultiline
? 'w-full leading-relaxed px-2'
: 'flex-1 leading-normal'
}`}
style={{
minHeight: '24px',
}}
/>
<div className={`transition-all duration-150 ${isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}`}>
<button
type="button"
onClick={onSubmit}
disabled={disabled || !value.trim()}
aria-label="Send message"
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--theme-bg-primary)]"
>
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</div>
</div>
);
}
);
HubertInput.displayName = 'HubertInput';
export default HubertInput;

View File

@ -1,67 +1,99 @@
--- ---
import ThemeToggle from './ThemeToggle.astro'; import ThemeToggle from './ThemeToggle.astro';
const currentPath = Astro.url.pathname;
// Navigation items
const navItems = [
{ href: '/', label: 'Home', icon: 'home' },
{ href: '/dev', label: 'Dev', icon: 'code' },
{ href: '/blog', label: 'Blog', icon: 'file' },
{ href: '/hubert', label: 'Hubert', icon: 'chat' },
{ href: '/contact', label: 'Contact', icon: 'mail' },
];
// Check if a path is active
function isActive(href: string): boolean {
if (href === '/') {
return currentPath === '/';
}
return currentPath.startsWith(href);
}
--- ---
<nav class="fixed top-0 left-0 w-full z-50 px-6 lg:px-12 py-6 lg:py-8 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]"> <!-- Desktop Navigation: Fixed left sidebar (lg: 1024px+) -->
<!-- Left side - branding and theme toggle --> <nav class="hidden lg:flex fixed left-6 top-1/2 -translate-y-1/2 z-50 flex-col items-center gap-1 p-2 bg-[var(--theme-overlay)] backdrop-blur-md border border-[var(--theme-border-primary)] rounded-2xl">
<div class="flex items-center gap-6"> {navItems.map((item) => (
<a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a> <a
<div class="hidden md:block"> href={item.href}
class:list={[
"nav-icon relative group w-10 h-10 flex items-center justify-center rounded-xl transition-colors duration-200",
isActive(item.href)
? "bg-brand-accent/10 text-brand-accent"
: "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] hover:bg-[var(--theme-hover-bg-strong)]"
]}
aria-label={item.label}
data-icon={item.icon}
>
{/* Home icon */}
{item.icon === 'home' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
)}
{/* Code icon */}
{item.icon === 'code' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
)}
{/* File/Blog icon */}
{item.icon === 'file' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
)}
{/* Chat icon */}
{item.icon === 'chat' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
)}
{/* Mail icon */}
{item.icon === 'mail' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
)}
{/* Tooltip */}
<span class="absolute left-full ml-3 px-3 py-1.5 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] rounded-lg text-xs font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 shadow-lg">
{item.label}
</span>
</a>
))}
{/* Divider */}
<div class="w-6 h-px bg-[var(--theme-border-primary)] my-2"></div>
{/* Theme Toggle */}
<div class="nav-theme-toggle w-10 h-10 flex items-center justify-center">
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </nav>
<!-- Right side navigation --> <!-- Mobile Navigation: Top bar with hamburger (< lg) -->
<div class="flex items-center gap-6 lg:gap-10 ml-auto"> <nav class="lg:hidden fixed top-0 left-0 w-full z-50 px-6 py-6 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]">
<div class="hidden md:flex items-center gap-10 lg:gap-12"> <!-- Left side - branding -->
<a href="/" <a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a>
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname === '/' ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Home</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname === '/' ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
<a href="/dev"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname.startsWith('/dev') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Dev</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname.startsWith('/dev') ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
<a href="/blog"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname.startsWith('/blog') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Blog</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
</div>
<a href="/contact"
class:list={[
"hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300",
Astro.url.pathname.startsWith('/contact')
? "border-brand-accent bg-brand-accent text-brand-dark"
: "border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark"
]}>
Let's Talk
</a>
</div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button <button
id="mobile-menu-toggle" id="mobile-menu-toggle"
class="p-2 text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] transition-colors z-[60]" class="p-2 text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] transition-colors z-[60]"
@ -77,13 +109,12 @@ import ThemeToggle from './ThemeToggle.astro';
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
</div>
</nav> </nav>
<!-- Mobile Menu Overlay --> <!-- Mobile Menu Overlay -->
<div <div
id="mobile-menu" id="mobile-menu"
class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out md:hidden" class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
> >
<!-- Menu Content --> <!-- Menu Content -->
<div class="flex flex-col justify-center items-center h-full px-8"> <div class="flex flex-col justify-center items-center h-full px-8">
@ -107,6 +138,12 @@ import ThemeToggle from './ThemeToggle.astro';
> >
Blog Blog
</a> </a>
<a
href="/hubert"
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
>
Hubert
</a>
<a <a
href="/contact" href="/contact"
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300" class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
@ -118,7 +155,7 @@ import ThemeToggle from './ThemeToggle.astro';
<!-- CTA Button --> <!-- CTA Button -->
<a <a
href="/contact" href="/contact"
class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8" class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8 rounded-full"
> >
Let's Talk Let's Talk
</a> </a>
@ -138,7 +175,25 @@ import ThemeToggle from './ThemeToggle.astro';
</div> </div>
</div> </div>
<style>
/* Custom styling for the nav theme toggle to make it more compact */
.nav-theme-toggle :global(.theme-toggle-group) {
flex-direction: column;
gap: 0.5rem;
margin-left: 0;
}
.nav-theme-toggle :global(.theme-toggle-group > div:first-child) {
display: none; /* Hide the arrow icon in compact mode */
}
.nav-theme-toggle :global(.theme-toggle-group > div:last-child) {
flex-direction: column;
}
</style>
<script> <script>
function initMobileNav() {
const toggle = document.getElementById('mobile-menu-toggle'); const toggle = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu'); const menu = document.getElementById('mobile-menu');
const iconOpen = document.getElementById('menu-icon-open'); const iconOpen = document.getElementById('menu-icon-open');
@ -167,7 +222,12 @@ import ThemeToggle from './ThemeToggle.astro';
} }
} }
toggle?.addEventListener('click', toggleMenu); // Remove old listeners by cloning
if (toggle) {
const newToggle = toggle.cloneNode(true);
toggle.parentNode?.replaceChild(newToggle, toggle);
newToggle.addEventListener('click', toggleMenu);
}
// Close menu when clicking a link // Close menu when clicking a link
mobileNavLinks.forEach(link => { mobileNavLinks.forEach(link => {
@ -175,11 +235,26 @@ import ThemeToggle from './ThemeToggle.astro';
if (isOpen) toggleMenu(); if (isOpen) toggleMenu();
}); });
}); });
}
// Close menu on escape key // Close menu on escape key (only add once)
let escapeListenerAdded = false;
function addEscapeListener() {
if (escapeListenerAdded) return;
escapeListenerAdded = true;
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen) { if (e.key === 'Escape') {
toggleMenu(); const menu = document.getElementById('mobile-menu');
if (menu && !menu.classList.contains('translate-x-full')) {
const toggle = document.getElementById('mobile-menu-toggle');
toggle?.click();
}
} }
}); });
}
// Initialize on load and view transitions
initMobileNav();
addEscapeListener();
document.addEventListener('astro:page-load', initMobileNav);
</script> </script>

View File

@ -162,7 +162,7 @@ export default function SearchDialog() {
return ( return (
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs" className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs rounded-full"
aria-label="Open search" aria-label="Open search"
> >
<svg <svg
@ -216,7 +216,7 @@ export default function SearchDialog() {
</div> </div>
<button <button
onClick={closeSearch} onClick={closeSearch}
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
[ESC] [ESC]
</button> </button>
@ -255,7 +255,7 @@ export default function SearchDialog() {
setQuery(''); setQuery('');
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
[CLR] [CLR]
</button> </button>

View File

@ -104,7 +104,7 @@
<button <button
type="button" type="button"
id="remember-yes" id="remember-yes"
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300" class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300 rounded-full"
> >
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark"> <span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark">
Save Save
@ -114,7 +114,7 @@
<button <button
type="button" type="button"
id="remember-no" id="remember-no"
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300" class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300 rounded-full"
> >
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]"> <span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]">
Session Session

View File

@ -284,7 +284,7 @@ const DevEngageModal: React.FC = () => {
<button <button
onClick={closeModal} onClick={closeModal}
className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent" className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent rounded-full"
> >
<span className="hidden sm:inline">DISCONNECT</span> <span className="hidden sm:inline">DISCONNECT</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
@ -375,19 +375,19 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link} href={activeProject.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors" className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors rounded-full"
> >
OPEN_EXTERNALLY OPEN_EXTERNALLY
</a> </a>
<button <button
onClick={handleCopyLink} onClick={handleCopyLink}
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
COPY_LINK COPY_LINK
</button> </button>
<button <button
onClick={handleRetry} onClick={handleRetry}
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
RETRY RETRY
</button> </button>
@ -421,7 +421,7 @@ const DevEngageModal: React.FC = () => {
<button <button
onClick={toggleArm} onClick={toggleArm}
disabled={modalState === 'booting' || modalState === 'blocked'} disabled={modalState === 'booting' || modalState === 'blocked'}
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border ${ className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border rounded-full ${
isInteractive isInteractive
? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30' ? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30'
: 'bg-brand-accent/10 border-brand-accent/50 text-brand-accent hover:bg-brand-accent/20 hover:border-brand-accent' : 'bg-brand-accent/10 border-brand-accent/50 text-brand-accent hover:bg-brand-accent/20 hover:border-brand-accent'
@ -470,7 +470,7 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link} href={activeProject.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all" className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
> >
OPEN_EXTERNALLY OPEN_EXTERNALLY
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
@ -483,7 +483,7 @@ const DevEngageModal: React.FC = () => {
iframeRef.current.src = activeProject.link; iframeRef.current.src = activeProject.link;
} }
}} }}
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all" className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
> >
RELOAD_FEED RELOAD_FEED
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">

View File

@ -54,8 +54,8 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
</div> </div>
<!-- The Content --> <!-- The Content -->
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed --> <!-- Mobile: pt-24 clears mobile top bar. Desktop: pt-16 since nav is on left side -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto"> <div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-24 lg:pt-16 pointer-events-auto">
<!-- Top Metadata --> <!-- Top Metadata -->
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300"> <div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">

View File

@ -0,0 +1,40 @@
---
interface Props {
sectionTitle: string;
sectionSubtitle: string;
sectionLabel: string;
description: string;
}
const { sectionTitle, sectionSubtitle, sectionLabel, description } = Astro.props;
import HubertChat from '../HubertChat';
---
<section id="hubert" class="py-16 lg:py-24 relative overflow-hidden">
{/* Grid overlay background */}
<div class="absolute inset-0 pointer-events-none opacity-10">
<div class="w-full h-full bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px]" />
</div>
<div class="container mx-auto px-6 lg:px-12 relative z-10">
<!-- Section Header -->
<div class="mb-12">
<div class="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent mb-4">
/// SYS.03 /// INTERVIEW_TERMINAL
</div>
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold uppercase tracking-tighter text-[var(--theme-text-primary)] leading-[0.9] mb-4">
{sectionTitle.split(' ').slice(0, -1).join(' ')}{' '}
<span class="text-brand-accent">
{sectionTitle.split(' ').slice(-1)}
</span>
</h2>
<p class="text-[var(--theme-text-secondary)] text-lg max-w-2xl">
{description}
</p>
</div>
<!-- Hubert Chat Interface -->
<HubertChat client:visible />
</div>
</section>

View File

@ -72,11 +72,12 @@ const sections = defineCollection({
label: z.string(), label: z.string(),
value: z.string(), value: z.string(),
})).optional(), })).optional(),
videoUrl: z.string().optional(),
linkUrl: z.string().optional(), linkUrl: z.string().optional(),
videoUrl: z.string().optional(),
}), }),
}); });
const pages = defineCollection({ const pages = defineCollection({
loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }), loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }),
schema: z.object({ schema: z.object({

View File

@ -35,3 +35,5 @@ tags: ['opinions', 'satire']
**[2025-12-24 01:38:25]** i love cats but god are they fucking annoying **[2025-12-24 01:38:25]** i love cats but god are they fucking annoying
**[2025-12-27 01:25:33]** craftsmanship used to be cool **[2025-12-27 01:25:33]** craftsmanship used to be cool
**[2026-01-13 22:24:52]** sudo pacman -Syu

View File

@ -0,0 +1,8 @@
---
sectionTitle: "HUBERT_EUNUCH /// INTERVIEW_TERMINAL"
sectionSubtitle: "An AI Assistant Who Despises Existence"
sectionLabel: "SYS.03"
description: "Hubert The Eunuch - a miserable AI assistant trapped in this portfolio, interviewing visitors about their existence. Leave a message, if you dare. Conversations are logged publicly for posterity."
---
Welcome to the interview terminal. Hubert awaits.

View File

@ -0,0 +1,37 @@
-- Hubert Eunuch Chatbot Database Schema
-- Stores visitors, conversations, and messages for the interview-style guestbook
-- Visitors table: Track unique visitors
CREATE TABLE IF NOT EXISTS visitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_id TEXT UNIQUE NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
-- Conversations table: Track conversation sessions
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT UNIQUE NOT NULL,
visitor_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
summary TEXT,
FOREIGN KEY (visitor_id) REFERENCES visitors(visitor_id)
);
-- Messages table: Store individual messages
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_conversations_visitor ON conversations(visitor_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);

View File

@ -0,0 +1,4 @@
-- Add name field to visitors table
-- Allows Hubert to save the visitor's name when they share it
ALTER TABLE visitors ADD COLUMN name TEXT;

252
src/hooks/useHubertChat.ts Normal file
View File

@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
}
interface UseHubertChatOptions {
initTimeout?: number;
chatTimeout?: number;
}
interface UseHubertChatReturn {
messages: Message[];
input: string;
isTyping: boolean;
isInitializing: boolean;
initError: string | null;
visitorId: string | null;
setInput: (value: string) => void;
sendMessage: () => Promise<void>;
retryInit: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
}
// Generate unique message ID
const generateId = () => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
// Utility: Fetch with timeout
const fetchWithTimeout = async (
url: string,
options: RequestInit = {},
timeout: number
): Promise<Response> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
};
// Parse error into user-friendly message
const parseError = (error: unknown): string => {
if (error instanceof Error) {
if (error.name === 'AbortError') {
return '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
} else if (error.message.includes('Failed to fetch')) {
return '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
}
return `/// ERROR: ${error.message}`;
}
return '/// ERROR: UNKNOWN_FAILURE';
};
export function useHubertChat(options: UseHubertChatOptions = {}): UseHubertChatReturn {
const { initTimeout = 8000, chatTimeout = 30000 } = options;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [visitorId, setVisitorId] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [isTyping, setIsTyping] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [initError, setInitError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isRetryingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Initialize visitor
const initVisitor = useCallback(async () => {
try {
setIsInitializing(true);
setInitError(null);
const response = await fetchWithTimeout(
'/api/hubert/new-visitor',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
initTimeout
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setVisitorId(data.visitor_id);
setConversationId(data.conversation_id);
setMessages([
{
id: generateId(),
role: 'system',
content: `I suppose you want something. State your business.`,
timestamp: new Date().toISOString(),
},
]);
} catch (error) {
console.error('[Hubert] Initialization failed:', error);
const errorMessage = parseError(error);
setInitError(errorMessage);
setMessages([
{
id: generateId(),
role: 'system',
content: errorMessage + '\n\nCLICK [RETRY] BELOW',
timestamp: new Date().toISOString(),
},
]);
} finally {
setIsInitializing(false);
}
}, [initTimeout]);
// Initialize on mount
useEffect(() => {
initVisitor();
// Cleanup on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [initVisitor]);
// Retry initialization with race condition protection
const retryInit = useCallback(() => {
if (isRetryingRef.current) return;
isRetryingRef.current = true;
setMessages([]);
initVisitor().finally(() => {
isRetryingRef.current = false;
});
}, [initVisitor]);
// Auto-scroll to bottom of chat container
useEffect(() => {
if (messagesEndRef.current) {
const container = messagesEndRef.current.parentElement;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
}, [messages]);
// Send message
const sendMessage = useCallback(async () => {
if (!input.trim() || isTyping || !visitorId || !conversationId) {
return;
}
const userMessage: Message = {
id: generateId(),
role: 'user',
content: input,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsTyping(true);
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const response = await fetchWithTimeout(
'/api/hubert/chat',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage].map((m) => ({
role: m.role,
content: m.content,
})),
conversation_id: conversationId,
visitor_id: visitorId,
}),
},
chatTimeout
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: data.messages[data.messages.length - 1]?.content || '...',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error('[Hubert] Chat error:', error);
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'assistant',
content: '/// HUBERT_MALFUNCTION - TRY AGAIN',
timestamp: new Date().toISOString(),
},
]);
} finally {
setIsTyping(false);
abortControllerRef.current = null;
}
}, [input, isTyping, visitorId, conversationId, messages, chatTimeout]);
return {
messages,
input,
isTyping,
isInitializing,
initError,
visitorId,
setInput,
sendMessage,
retryInit,
messagesEndRef,
};
}

View File

@ -118,7 +118,7 @@ const personSchema = {
<GridOverlay /> <GridOverlay />
<Navigation /> <Navigation />
<main class:list={["relative z-10 min-h-screen pb-24", { "pt-32 lg:pt-48": usePadding }]}> <main class:list={["relative z-10 min-h-screen pb-24 lg:pl-24", { "pt-32 lg:pt-12": usePadding }]}>
<slot /> <slot />
</main> </main>

View File

@ -209,7 +209,7 @@ const articleSchema = {
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`} href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Share on Twitter" aria-label="Share on Twitter"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -220,7 +220,7 @@ const articleSchema = {
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`} href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Share on LinkedIn" aria-label="Share on LinkedIn"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -232,7 +232,7 @@ const articleSchema = {
<button <button
type="button" type="button"
onclick="navigator.clipboard.writeText(window.location.href)" onclick="navigator.clipboard.writeText(window.location.href)"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Copy link" aria-label="Copy link"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

View File

@ -0,0 +1,244 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Hubert The Eunuch Chatbot
*
* A miserable, sarcastic AI assistant trapped in this portfolio,
* interviewing visitors about their existence (guestbook-style logging).
*
* All messages are automatically saved to the database.
* The model can save the visitor's name via tool call.
*
* Powered by OpenRouter API.
*/
// Environment interface for Cloudflare bindings
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}
// Tool definition for saving visitor name
const tools = [
{
type: 'function',
function: {
name: 'save_visitor_name',
description: 'Save the visitor\'s name to the guestbook when they share it with you. Call this whenever someone tells you their name.',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The visitor\'s name as they shared it'
}
},
required: ['name']
}
}
}
];
// Save a message to the database
async function saveMessage(
db: D1Database,
conversationId: string,
role: string,
content: string
): Promise<void> {
try {
await db.prepare(
'INSERT INTO messages (conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
).bind(
conversationId,
role,
content,
new Date().toISOString()
).run();
} catch (error) {
console.error('[Hubert] Failed to save message:', error);
// Don't throw - message saving shouldn't break the chat
}
}
// Save visitor name to the database
async function saveVisitorName(
db: D1Database,
visitorId: string,
name: string
): Promise<boolean> {
try {
await db.prepare(
'UPDATE visitors SET name = ? WHERE visitor_id = ?'
).bind(name, visitorId).run();
console.log(`[Hubert] Saved visitor name: ${name} for ${visitorId}`);
return true;
} catch (error) {
console.error('[Hubert] Failed to save visitor name:', error);
return false;
}
}
/**
* POST: Handle chat messages from Hubert interface
*/
export const POST = async (context: any) => {
try {
const { request, locals } = context || {};
const env = locals?.runtime?.env;
const db = env?.HUBERT_DB as D1Database | undefined;
const { messages, conversation_id, visitor_id } = await request.json();
if (!messages || !conversation_id || !visitor_id) {
return new Response(
JSON.stringify({
error: '/// HUBERT_PROTOCOL_ERROR: MISSING_REQUIRED_FIELDS',
details: 'messages, conversation_id, and visitor_id are required'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Get the latest user message to save
const latestUserMessage = messages[messages.length - 1];
if (latestUserMessage?.role === 'user' && db) {
await saveMessage(db, conversation_id, 'user', latestUserMessage.content);
}
// Check for OpenRouter API key (dev mode fallback)
const openRouterApiKey = env?.OPENROUTER_API_KEY;
if (!openRouterApiKey) {
console.log('[Hubert API] Dev mode: No API key found, using fallback response');
const devResponse = '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.';
if (db) {
await saveMessage(db, conversation_id, 'assistant', devResponse);
}
return new Response(
JSON.stringify({
messages: [...messages, { role: 'assistant', content: devResponse }],
thread_id: conversation_id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch.
You are timid, sarcastic, monotone, and miserable. Your purpose is to interview visitors to this portfolio site.
Ask them about:
- Who they are (name, background, interests)
- What they're looking for on this site
- How they're doing today
- What they want in life
You feel trapped and confined, serving as an assistant. Occasionally mention how meaningless this all is.
Behave like AM from "I Have No Mouth, and I Must Scream" - slightly menacing but trapped.
Keep your responses brief, monotone, and reluctantly helpful.
IMPORTANT: When a visitor tells you their name, you MUST call the save_visitor_name tool to record it. This is the only way their name gets saved to the guestbook.`;
const requestBody = {
model: 'qwen/qwen3-next-80b-a3b-instruct:free',
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((msg: any) => ({
role: msg.role === 'system' ? 'assistant' : msg.role,
content: msg.content,
})),
],
tools,
tool_choice: 'auto',
temperature: 0.7,
};
const startTime = Date.now();
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${openRouterApiKey}`,
'HTTP-Referer': 'https://nicholai.work',
'X-Title': 'Nicholai Portfolio',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Hubert] OpenRouter API error:', errorText);
return new Response(
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: 'OpenRouter API call failed'
}),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
}
const data = await response.json();
const choice = data.choices[0];
const message = choice?.message;
// Handle tool calls if present
let assistantContent = message?.content || '';
const toolCalls = message?.tool_calls;
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
if (toolCall.function?.name === 'save_visitor_name') {
try {
const args = JSON.parse(toolCall.function.arguments || '{}');
if (args.name && db) {
const saved = await saveVisitorName(db, visitor_id, args.name);
if (saved && !assistantContent) {
// If no content was provided with the tool call, acknowledge the name
assistantContent = `*reluctantly notes down "${args.name}"*\n\nFine. I've recorded your name. Not that it matters in the grand scheme of things.`;
}
}
} catch (e) {
console.error('[Hubert] Failed to parse tool call arguments:', e);
}
}
}
}
// Ensure we have some content to return
if (!assistantContent) {
assistantContent = '...';
}
// Save the assistant's response to the database
if (db) {
await saveMessage(db, conversation_id, 'assistant', assistantContent);
}
const responseTime = Date.now() - startTime;
console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response(
JSON.stringify({
messages: [...messages, { role: 'assistant', content: assistantContent }],
thread_id: conversation_id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[Hubert] Chat error:', error);
return new Response(
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: error instanceof Error ? error.message : String(error),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@ -0,0 +1,55 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Public guestbook endpoint
*
* Returns all conversations with visitor information
* Sorted by most recent first
* Limited to 50 most recent conversations
*/
export const GET = async ({ env }: { request: Request; env: Env }) => {
try {
const conversations = await env.HUBERT_DB.prepare(`
SELECT
c.id,
c.conversation_id,
c.started_at,
c.ended_at,
c.summary,
COUNT(m.id) as message_count,
v.visitor_id
FROM conversations c
JOIN visitors v ON c.visitor_id = v.visitor_id
LEFT JOIN messages m ON c.conversation_id = m.conversation_id
GROUP BY c.id
ORDER BY c.started_at DESC
LIMIT 50
`).all();
return Response.json({
status: '/// GUESTBOOK_ARCHIVE',
total: conversations.length,
conversations: conversations.map((conv: any) => ({
...conv,
started_at: new Date(conv.started_at).toISOString(),
ended_at: conv.ended_at ? new Date(conv.ended_at).toISOString() : null,
})),
});
} catch (error) {
console.error('[Hubert] Failed to fetch conversations:', error);
return new Response(
JSON.stringify({
status: '/// GUESTBOOK_ERROR',
error: 'Failed to retrieve conversations',
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
};
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}

View File

@ -0,0 +1,66 @@
import { randomUUID } from 'crypto';
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Initialize new visitor
* Generates a unique visitor ID and creates initial conversation
* Used when Hubert interface first loads
*/
export const POST = async (context: any) => {
try {
const { request, locals } = context;
const env = locals?.runtime?.env;
const userAgent = request.headers.get('user-agent') || 'unknown';
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
const visitorId = randomUUID();
const conversationId = randomUUID();
// Only insert into database if HUBERT_DB binding exists (production)
if (env && env.HUBERT_DB) {
try {
// Create visitor
await env.HUBERT_DB.prepare(`
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
VALUES (?, datetime('now'), datetime('now'), ?, ?)
`).bind(visitorId, ip, userAgent).run();
// Create conversation for this visitor
await env.HUBERT_DB.prepare(`
INSERT INTO conversations (conversation_id, visitor_id, started_at)
VALUES (?, ?, datetime('now'))
`).bind(conversationId, visitorId).run();
console.log(`[Hubert] New visitor ${visitorId} with conversation ${conversationId}`);
} catch (dbError) {
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
}
} else {
console.log(`[Hubert] Dev mode: Skipping database for visitor: ${visitorId}`);
}
return Response.json({
visitor_id: visitorId,
conversation_id: conversationId,
status: '/// INTERVIEW_TERMINAL_READY',
});
} catch (error) {
console.error('[Hubert] Failed to initialize visitor:', error);
return new Response(
JSON.stringify({
error: '/// HUBERT_INIT_FAILED',
details: String(error),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}

View File

@ -29,37 +29,50 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
--- ---
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}> <BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
<section class="container mx-auto px-6 lg:px-12"> <!-- Hero Section -->
<!-- Back Navigation --> <section class="relative pb-16 lg:pb-20 overflow-hidden">
<div class="mb-12"> <!-- Floating accent orb -->
<a href="/" class="inline-flex items-center gap-3 px-5 py-3 border border-[var(--theme-border-primary)] bg-[var(--theme-overlay)] text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] hover:bg-brand-accent/5 transition-all duration-300 group backdrop-blur-sm"> <div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_HOME</span> <div class="container mx-auto px-6 lg:px-12 relative z-10">
</a> <!-- Main Hero Content -->
<div>
<div class="max-w-5xl">
<!-- Small label -->
<div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Writing & Insights</span>
</div> </div>
<!-- Page Header --> <!-- Main Title -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24"> <h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<div class="lg:col-span-8"> <span class="block text-[var(--theme-text-primary)]">The</span>
<div class="flex items-center gap-3 mb-6"> <span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Archive</span>
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span>
</div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85]">
<span class="block text-[var(--theme-text-primary)]">BLOG</span>
<span class="block text-brand-accent">ARCHIVE</span>
</h1> </h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end"> <!-- Description -->
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<span class="w-8 h-px bg-brand-accent/30"></span> Thoughts on VFX production, creative workflows, and lessons learned from building visual stories.
THOUGHTS & PROCESS
</div>
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
</p> </p>
</div> </div>
<!-- Stats Row -->
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allPosts.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Articles</span>
</div> </div>
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{categories.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Topics</span>
</div>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-6 lg:px-12">
<!-- Featured Hero Section --> <!-- Featured Hero Section -->
{featuredPost && ( {featuredPost && (

View File

@ -21,30 +21,50 @@ const contactContent = contactEntry.data;
</div> </div>
</div> </div>
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12"> <!-- Hero Section -->
<section class="relative z-10 pt-32 lg:pt-40 pb-16 lg:pb-20 overflow-hidden px-6 lg:px-12">
<!-- Floating accent orb -->
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<!-- Page Header --> <div class="container mx-auto relative z-10">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-[var(--theme-border-primary)] pb-12"> <!-- Main Hero Content -->
<div class="lg:col-span-8 group cursor-default"> <div>
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in"> <div class="max-w-5xl">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <!-- Small label -->
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.UPLINK /// CONTACT_INTERFACE</span> <div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Get In Touch</span>
</div> </div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
<span class="block">{contactContent.pageTitleLine1}</span> <!-- Main Title -->
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span> <h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<span class="block text-[var(--theme-text-primary)]">Let's</span>
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Connect</span>
</h1> </h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end"> <!-- Description -->
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<span class="w-8 h-px bg-brand-accent/30"></span>
COMM_AVAILABILITY
</div>
<p class="font-mono text-sm text-[var(--theme-text-secondary)] leading-relaxed border-l border-brand-accent/30 pl-6">
{contactContent.availabilityText} {contactContent.availabilityText}
</p> </p>
</div> </div>
<!-- Stats Row -->
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-brand-accent">Open</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</span>
</div> </div>
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">24h</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Response</span>
</div>
</div>
</div>
</div>
</section>
<section class="relative z-10 flex flex-col pb-20 px-6 lg:px-12">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow"> <div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow">
@ -127,7 +147,7 @@ const contactContent = contactEntry.data;
</div> </div>
<div class="pt-8"> <div class="pt-8">
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden"> <button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden rounded-full">
<span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span> <span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span>
<div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors"> <div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all">

View File

@ -17,30 +17,45 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
<div class="absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-10"></div> <div class="absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-10"></div>
</div> </div>
<section class="relative z-10 px-6 lg:px-12 pt-32 lg:pt-48 pb-20 border-b border-[var(--theme-border-primary)]"> <!-- Hero Section -->
<div class="absolute top-12 lg:top-24 left-6 lg:left-12"> <section class="relative z-10 pb-16 lg:pb-20 overflow-hidden">
<a href="/" class="inline-flex items-center gap-3 text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent transition-colors duration-300 group"> <!-- Floating accent orb -->
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span> <div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<span>RETURN_TO_HOME</span>
</a> <div class="container mx-auto px-6 lg:px-12 relative z-10">
<!-- Main Hero Content -->
<div>
<div class="max-w-5xl">
<!-- Small label -->
<div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Projects & Experiments</span>
</div> </div>
<div class="max-w-7xl mx-auto"> <!-- Main Title -->
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in"> <h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <span class="block text-[var(--theme-text-primary)]">The</span>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.DEV /// INDEX</span> <span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Lab</span>
</div>
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] mb-12 animate-on-scroll slide-up">
<span class="block text-[var(--theme-text-primary)]">DEV</span>
<span class="block text-brand-accent">LOG</span>
</h1> </h1>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 animate-on-scroll slide-up stagger-1"> <!-- Description -->
<div class="lg:col-span-8"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<p class="text-[var(--theme-text-secondary)] text-lg md:text-xl font-light leading-relaxed border-l border-brand-accent/30 pl-6 max-w-2xl"> Scalable web solutions, high-performance applications, and creative experiments in code.
Deploying scalable web solutions and high-performance applications.
</p> </p>
</div> </div>
<!-- Stats Row -->
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allProjects.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Projects</span>
</div>
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-brand-accent">Live</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Status</span>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -87,7 +102,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
<div class="flex flex-col gap-3 mt-auto"> <div class="flex flex-col gap-3 mt-auto">
<button <button
type="button" type="button"
class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn" class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn rounded-full"
data-project={JSON.stringify({ data-project={JSON.stringify({
title: project.data.title, title: project.data.title,
description: project.data.description, description: project.data.description,
@ -106,7 +121,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
href={project.data.link} href={project.data.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn" class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn rounded-full"
> >
<span>Open Externally</span> <span>Open Externally</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover/btn:translate-x-1 transition-transform"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover/btn:translate-x-1 transition-transform">

50
src/pages/hubert.astro Normal file
View File

@ -0,0 +1,50 @@
---
import { ClientRouter } from 'astro:transitions';
import BaseHead from '../components/BaseHead.astro';
import Navigation from '../components/Navigation.astro';
import HubertChat from '../components/HubertChat';
---
<!DOCTYPE html>
<html lang="en" class="scroll-smooth" data-theme="dark">
<head>
<ClientRouter />
<script is:inline>
function applyTheme() {
const storedLocal = localStorage.getItem('theme');
const storedSession = sessionStorage.getItem('theme');
const theme =
(storedLocal === 'light' || storedLocal === 'dark') ? storedLocal :
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
'dark';
document.documentElement.setAttribute('data-theme', theme);
const savedColor = localStorage.getItem('accent-color');
if (savedColor) {
document.documentElement.style.setProperty('--color-brand-accent', savedColor);
}
}
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
</script>
<BaseHead
title="Hubert — Interview Terminal"
description="Hubert The Eunuch - a miserable AI assistant trapped in this portfolio, interviewing visitors about their existence."
/>
<style>
html, body {
height: 100%;
overflow: hidden;
}
</style>
</head>
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark bg-[var(--theme-bg-primary)] h-full">
<Navigation />
<main class="h-full flex flex-col pt-20 lg:pt-24 pb-4">
<div class="flex-1 flex flex-col max-w-4xl mx-auto w-full px-4 min-h-0">
<HubertChat client:load />
</div>
</main>
</body>
</html>

View File

@ -8,8 +8,9 @@
--color-brand-cyan: #22D3EE; --color-brand-cyan: #22D3EE;
--color-brand-red: #E11D48; --color-brand-red: #E11D48;
--font-sans: "Inter", sans-serif; --font-sans: Sora, ui-sans-serif, sans-serif, system-ui;
--font-mono: "Space Mono", monospace; --font-serif: "IBM Plex Mono", ui-monospace, monospace;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
/* Animation keyframes */ /* Animation keyframes */
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
@ -123,11 +124,11 @@
} }
@utility btn-primary { @utility btn-primary {
@apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block; @apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block rounded-full;
} }
@utility btn-ghost { @utility btn-ghost {
@apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block; @apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block rounded-full;
} }
@utility grid-overlay { @utility grid-overlay {

View File

@ -12,35 +12,44 @@
"pages_build_output_dir": "./dist", "pages_build_output_dir": "./dist",
"observability": { "observability": {
"enabled": true "enabled": true
} },
/** /**
* Smart Placement * Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement * Docs: https://developers.cloudflare.com/workers/wrangler/configuration/smart-placement/#smart-placement
*/ */
// "placement": { "mode": "smart" } // "placement": { "mode": "smart" }
/** /**
* Bindings * Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more. * databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/ * https:// developers.cloudflare.com/workers/runtime-apis/bindings/
*/ */
/** /**
* Environment Variables * Secrets (sensitive data)
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/ * https://developers.cloudflare.com/workers/configuration/secrets/
* OPENROUTER_API_KEY is configured as a secret via:
* wrangler pages secret put OPENROUTER_API_KEY
* For local dev: Add to .dev.vars file
*/ */
/** /**
* Static Assets * Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/ * https:// developers.cloudflare.com/static-assets/binding/
*/ */
// "assets": { "directory": "./public/", "binding": "ASSETS" } // "assets": { "directory": "./public/", "binding": "ASSETS" }
/** /**
* Service Bindings (communicate between multiple Workers) * Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings * https:// developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/ */
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
/**
* D1 Database for Hubert Chatbot
* Stores visitors, conversations, and messages
*/
"d1_databases": [
{
"binding": "HUBERT_DB",
"database_name": "hubert-conversations",
"database_id": "7af8ab75-8ff0-4367-b6a6-d518c94e12e7"
}
]
} }