diff --git a/package.json b/package.json
index a02dfb6..b231fc1 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,8 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"astro": "^5.16.4",
+ "dompurify": "^3.3.1",
+ "framer-motion": "^12.26.2",
"lunr": "^2.3.9",
"marked": "^17.0.1",
"react": "^19.2.1",
@@ -40,6 +42,7 @@
"zod": "^4.3.4"
},
"devDependencies": {
+ "@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1",
"wrangler": "^4.53.0"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6544cde..8681746 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,12 @@ importers:
astro:
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)
+ 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:
specifier: ^2.3.9
version: 2.3.9
@@ -72,6 +78,9 @@ importers:
specifier: ^4.3.4
version: 4.3.4
devDependencies:
+ '@types/dompurify':
+ specifier: ^3.2.0
+ version: 3.2.0
'@types/node':
specifier: ^24.10.1
version: 24.10.1
@@ -1361,6 +1370,10 @@ packages:
'@types/debug@4.1.12':
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':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1405,6 +1418,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1710,6 +1726,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
+ dompurify@3.3.1:
+ resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -1836,6 +1855,20 @@ packages:
fontkit@2.0.4:
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:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2282,6 +2315,12 @@ packages:
engines: {node: '>=18.0.0'}
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:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -4033,6 +4072,10 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
+ '@types/dompurify@3.2.0':
+ dependencies:
+ dompurify: 3.3.1
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -4079,6 +4122,9 @@ snapshots:
dependencies:
'@types/node': 24.10.1
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -4426,6 +4472,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
+ dompurify@3.3.1:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -4629,6 +4679,15 @@ snapshots:
unicode-properties: 1.4.1
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:
optional: true
@@ -5404,6 +5463,12 @@ snapshots:
- bufferutil
- utf-8-validate
+ motion-dom@12.26.2:
+ dependencies:
+ motion-utils: 12.24.10
+
+ motion-utils@12.24.10: {}
+
mrmime@2.0.1: {}
ms@2.1.3: {}
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro
index f39db61..a06997e 100644
--- a/src/components/BaseHead.astro
+++ b/src/components/BaseHead.astro
@@ -124,14 +124,14 @@ const professionalServiceSchema = {
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index 8855e9a..df0c71d 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -19,13 +19,13 @@ const today = new Date();
diff --git a/src/components/HubertChat.tsx b/src/components/HubertChat.tsx
index 2c16232..a35619e 100644
--- a/src/components/HubertChat.tsx
+++ b/src/components/HubertChat.tsx
@@ -1,371 +1,339 @@
-import React, { useState, useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState } from 'react';
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+import { motion } from 'framer-motion';
+import { useHubertChat } from '../hooks/useHubertChat';
-interface Message {
- role: 'user' | 'assistant' | 'system';
- content: string;
- timestamp: string;
+// 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);
}
-// Utility: Fetch with timeout
-const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeout = 8000) => {
- 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;
- }
-};
-
export default function HubertChat() {
- const [messages, setMessages] = useState([]);
- const [input, setInput] = useState('');
- const [visitorId, setVisitorId] = useState(null);
- const [conversationId, setConversationId] = useState(null);
- const [isTyping, setIsTyping] = useState(false);
- const [isInitializing, setIsInitializing] = useState(true);
- const [initError, setInitError] = useState(null);
- const messagesEndRef = useRef(null);
+ const {
+ messages,
+ input,
+ isTyping,
+ isInitializing,
+ initError,
+ setInput,
+ sendMessage,
+ retryInit,
+ messagesEndRef,
+ } = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
- // Initialize visitor on mount
- useEffect(() => {
- const initVisitor = async () => {
- try {
- setIsInitializing(true);
- setInitError(null);
+ const textareaRef = useRef(null);
+ const [inputHeight, setInputHeight] = useState(40);
- const response = await fetchWithTimeout('/api/hubert/new-visitor', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- }, 8000); // 8 second timeout
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
-
- const data = await response.json();
-
- setVisitorId(data.visitor_id);
- setConversationId(data.conversation_id);
-
- // Add system welcome message from Hubert
- setMessages([{
- role: 'system',
- content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`,
- timestamp: new Date().toISOString(),
- }]);
- } catch (error) {
- console.error('[Hubert] Initialization failed:', error);
-
- let errorMessage = '/// ERROR: UNKNOWN_FAILURE';
-
- if (error instanceof Error) {
- if (error.name === 'AbortError') {
- errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
- } else if (error.message.includes('Failed to fetch')) {
- errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
- } else {
- errorMessage = `/// ERROR: ${error.message}`;
- }
- }
-
- setInitError(errorMessage);
- setMessages([{
- role: 'system',
- content: errorMessage + '\\n\\nCLICK [RETRY] BELOW',
- timestamp: new Date().toISOString(),
- }]);
- } finally {
- setIsInitializing(false);
- }
- };
- initVisitor();
- }, []);
-
- // Retry initialization
- const retryInit = () => {
- setIsInitializing(true);
- setInitError(null);
- setMessages([]);
-
- // Re-trigger initialization
- const initVisitor = async () => {
- try {
- setIsInitializing(true);
- setInitError(null);
-
- const response = await fetchWithTimeout('/api/hubert/new-visitor', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- }, 8000);
-
- 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([{
- role: 'system',
- content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`,
- timestamp: new Date().toISOString(),
- }]);
- } catch (error) {
- console.error('[Hubert] Initialization failed:', error);
-
- let errorMessage = '/// ERROR: UNKNOWN_FAILURE';
-
- if (error instanceof Error) {
- if (error.name === 'AbortError') {
- errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
- } else if (error.message.includes('Failed to fetch')) {
- errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
- } else {
- errorMessage = `/// ERROR: ${error.message}`;
- }
- }
-
- setInitError(errorMessage);
- setMessages([{
- role: 'system',
- content: errorMessage + '\\n\\nCLICK [RETRY] BELOW',
- timestamp: new Date().toISOString(),
- }]);
- } finally {
- setIsInitializing(false);
- }
- };
- initVisitor();
- };
-
- // Auto-scroll to bottom of chat container (not entire page)
- useEffect(() => {
- if (messagesEndRef.current) {
- const container = messagesEndRef.current.parentElement;
- if (container) {
- container.scrollTop = container.scrollHeight;
- }
- }
- }, [messages]);
-
- const sendMessage = async () => {
- if (!input.trim() || isTyping || !visitorId || !conversationId) {
- return;
- }
- const userMessage: Message = {
- role: 'user',
- content: input,
- timestamp: new Date().toISOString(),
- };
-
- setMessages(prev => [...prev, userMessage]);
- setInput('');
- setIsTyping(true);
-
- try {
- const response = await fetch('/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,
- }),
- });
-
- 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 = {
- 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, {
- role: 'assistant',
- content: '/// HUBERT_MALFUNCTION - TRY AGAIN',
- timestamp: new Date().toISOString(),
- }]);
- } finally {
- setIsTyping(false);
+ // Auto-resize textarea and track height for dynamic border radius
+ const adjustTextareaHeight = () => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = 'auto';
+ const newHeight = Math.min(textarea.scrollHeight, 200);
+ textarea.style.height = `${newHeight}px`;
+ setInputHeight(newHeight);
}
};
- // Show loading or error state
- if (isInitializing || initError) {
+ useEffect(() => {
+ adjustTextareaHeight();
+ }, [input]);
+
+ // Check if multiline for padding adjustments
+ const isMultiline = inputHeight > 48;
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ };
+
+ // Initial/Loading state - centered branding with input
+ if (isInitializing && !initError) {
return (
-
-
- {isInitializing && !initError ? (
- <>
-
-
-
- HUBERT_IS_BOOTING...
-
-
-
- Initializing chatbot... Check console for debug info.
-
- >
- ) : initError ? (
- <>
-
-
-
- HUBERT_INITIALIZATION_FAILED
-
-
-
- {initError}
-
-
-
- Check browser console for detailed error information.
-
- >
- ) : null}
+
+ {/* Branding */}
+
+
Waking up...
+
+ Loading Hubert chat interface
+
);
}
- return (
-
- {/* Header */}
-
-
-
-
- /// HUBERT_EUNUCH /// ONLINE
-
-
-
- {visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'}
-
+ // Error state
+ if (initError) {
+ return (
+
+
Hubert
+
+ {initError}
+
+
+ );
+ }
- {/* Messages */}
-
- {messages.map((msg, idx) => (
-
+ {/* Branding */}
+
Hubert
+
+ {/* Input bar */}
+
+
-
-
- {msg.role === 'user' ? 'YOU' : 'HUBERT'}
-
-
-
- {msg.content}
-
-
- {new Date(msg.timestamp).toLocaleString('en-US', {
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false,
- })}
-
-
-
-
- ))}
-
- {/* Typing indicator */}
- {isTyping && (
-
-
-
-
-
-
- HUBERT_IS_PONDERING...
-
-
-
-
-
- )}
-
-
-
- {/* Input */}
-
-
-
- setInput(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
- placeholder="/// HUBERT_AWAITS_INPUT..."
- className="w-full bg-transparent border-b-2 border-[var(--theme-border-primary)] py-3 text-lg font-mono text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] focus:border-brand-accent focus:outline-none transition-colors"
+ onKeyDown={handleKeyDown}
+ placeholder="What do you want to know?"
+ 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 max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`}
/>
-
-
+
+
+
+
+
+
+ {/* Subtitle */}
+
+ A miserable AI assistant, here to interview you.
+
+
+ );
+ }
+
+ // Chat mode - messages with input at bottom
+ return (
+
+ {/* Messages area - scrollable */}
+
+
+ {messages.map((msg, index) => (
+
+ {msg.role === 'user' ? (
+ // User message - right aligned pill with markdown
+
+ ) : (
+ // Assistant message - left aligned with subtle label and markdown
+
+ {(index === 0 || messages[index - 1]?.role === 'user') && (
+
+ Hubert
+
+ )}
+
+
+ )}
+
+ ))}
+
+ {/* Typing indicator */}
+ {isTyping && (
+
+ )}
+
+
+ {/* Input bar - pinned to bottom */}
+
+
+ {/* Styles for markdown content */}
+
);
}
diff --git a/src/components/Navigation.astro b/src/components/Navigation.astro
index 939f8a1..3de6e8c 100644
--- a/src/components/Navigation.astro
+++ b/src/components/Navigation.astro
@@ -47,13 +47,24 @@ import ThemeToggle from './ThemeToggle.astro';
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
]}>
+
+ Hubert
+
+
Let's Talk
@@ -107,6 +118,12 @@ import ThemeToggle from './ThemeToggle.astro';
>
Blog
+
+ Hubert
+
Let's Talk
diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx
index b2c6617..7712152 100644
--- a/src/components/SearchDialog.tsx
+++ b/src/components/SearchDialog.tsx
@@ -162,7 +162,7 @@ export default function SearchDialog() {
return (
diff --git a/src/components/ThemePreferenceDialog.astro b/src/components/ThemePreferenceDialog.astro
index 11366ae..9161426 100644
--- a/src/components/ThemePreferenceDialog.astro
+++ b/src/components/ThemePreferenceDialog.astro
@@ -104,7 +104,7 @@
-
+
diff --git a/src/content.config.ts b/src/content.config.ts
index 76a4eb7..dae52ac 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -73,6 +73,7 @@ const sections = defineCollection({
value: z.string(),
})).optional(),
linkUrl: z.string().optional(),
+ videoUrl: z.string().optional(),
}),
});
diff --git a/src/hooks/useHubertChat.ts b/src/hooks/useHubertChat.ts
new file mode 100644
index 0000000..82011b4
--- /dev/null
+++ b/src/hooks/useHubertChat.ts
@@ -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
;
+ retryInit: () => void;
+ messagesEndRef: React.RefObject;
+}
+
+// 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 => {
+ 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([]);
+ const [input, setInput] = useState('');
+ const [visitorId, setVisitorId] = useState(null);
+ const [conversationId, setConversationId] = useState(null);
+ const [isTyping, setIsTyping] = useState(false);
+ const [isInitializing, setIsInitializing] = useState(true);
+ const [initError, setInitError] = useState(null);
+
+ const messagesEndRef = useRef(null);
+ const isRetryingRef = useRef(false);
+ const abortControllerRef = useRef(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,
+ };
+}
diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro
index f841954..5681044 100644
--- a/src/layouts/BlogPost.astro
+++ b/src/layouts/BlogPost.astro
@@ -209,7 +209,7 @@ const articleSchema = {
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
target="_blank"
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"
>
@@ -220,7 +220,7 @@ const articleSchema = {
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
target="_blank"
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"
>
@@ -232,7 +232,7 @@ const articleSchema = {
diff --git a/src/pages/api/hubert/chat.ts b/src/pages/api/hubert/chat.ts
index acec8b4..50ff862 100644
--- a/src/pages/api/hubert/chat.ts
+++ b/src/pages/api/hubert/chat.ts
@@ -1,12 +1,6 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
-import { ChatOpenAI } from '@langchain/openai';
-import { tool } from '@langchain/core/tools';
-import { z } from 'zod';
-import { getCollection } from 'astro:content';
-import { HumanMessage, AIMessage } from '@langchain/core/messages';
-
/**
* Hubert The Eunuch Chatbot
*
@@ -22,50 +16,6 @@ export interface Env {
OPENROUTER_API_KEY: string;
}
-/**
- * Tool: Search blog content (RAG)
- * Searches portfolio blog for relevant content when user asks questions
- * about the site, projects, or blog posts.
- */
-const searchBlog = tool(
- async (input: { query: string }) => {
- try {
- const blog = await getCollection('blog');
-
- const queryLower = input.query.toLowerCase();
- const results = blog.filter(post =>
- post.data.title.toLowerCase().includes(queryLower) ||
- post.data.description.toLowerCase().includes(queryLower) ||
- post.body.toLowerCase().includes(queryLower)
- ).slice(0, 3);
-
- console.log(`[Hubert] Blog search for "${input.query}" returned ${results.length} results`);
-
- return {
- results: results.map(post => ({
- title: post.data.title,
- url: `/blog/${post.id}/`,
- description: post.data.description,
- })),
- count: results.length,
- };
- } catch (error) {
- console.error('[Hubert] Blog search failed:', error);
- return {
- error: 'Failed to search blog content',
- details: String(error),
- };
- }
- },
- {
- name: 'search_blog',
- description: 'Search portfolio blog for relevant content when user asks questions about the site, projects, or blog posts.',
- schema: z.object({
- query: z.string().describe('Search query for blog content'),
- }),
- },
-);
-
/**
* POST: Handle chat messages from Hubert interface
*/
@@ -108,9 +58,6 @@ export const POST = async (context) => {
}
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
-
- const lastMessage = messages[messages.length - 1];
- const userContent = lastMessage.content;
const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch.
@@ -144,6 +91,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
temperature: 0.7,
};
+ const startTime = Date.now();
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
@@ -153,6 +101,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
'X-Title': 'Nicholai Portfolio',
},
body: JSON.stringify(requestBody),
+ signal: AbortSignal.timeout(25000), // 25 second timeout
});
if (!response.ok) {
@@ -170,7 +119,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
const data = await response.json();
const assistantContent = data.choices[0]?.message?.content || '...';
- const responseTime = response.headers.get('date') ? Date.now() - Date.parse(response.headers.get('date')) : 0;
+ const responseTime = Date.now() - startTime;
console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response(
diff --git a/src/pages/api/hubert/conversations.ts b/src/pages/api/hubert/conversations.ts
index aee7ada..6fbea95 100644
--- a/src/pages/api/hubert/conversations.ts
+++ b/src/pages/api/hubert/conversations.ts
@@ -1,5 +1,3 @@
-import { getEntry } from 'astro:content';
-
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
@@ -41,11 +39,13 @@ export const GET = async ({ env }: { request: Request; env: Env }) => {
} catch (error) {
console.error('[Hubert] Failed to fetch conversations:', error);
- return Response.json({
- status: '/// GUESTBOOK_ERROR',
- error: 'Failed to retrieve conversations',
- }),
- { status: 500, headers: { 'Content-Type': 'application/json' } }
+ return new Response(
+ JSON.stringify({
+ status: '/// GUESTBOOK_ERROR',
+ error: 'Failed to retrieve conversations',
+ }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ )
}
};
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro
index 51e9a66..ccd4dd7 100644
--- a/src/pages/blog/index.astro
+++ b/src/pages/blog/index.astro
@@ -29,37 +29,50 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
---
-
-
-
+
+
+
+
-
-
-
-
-
-
SYS.LOG /// PRODUCTION_ARCHIVE
+
+
+
+
+
+
+
+
+
+ The
+ Archive
+
+
+
+
+ Thoughts on VFX production, creative workflows, and lessons learned from building visual stories.
+
-
- BLOG
- ARCHIVE
-
-
-
-
-
- THOUGHTS & PROCESS
+
+
+
+
+ {allPosts.length}
+ Articles
+
+
+
+ {categories.length}
+ Topics
+
-
- Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
-
+
+
+
{featuredPost && (
diff --git a/src/pages/contact.astro b/src/pages/contact.astro
index 90d1eff..3acb121 100644
--- a/src/pages/contact.astro
+++ b/src/pages/contact.astro
@@ -21,30 +21,50 @@ const contactContent = contactEntry.data;
-