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>
This commit is contained in:
parent
e46306389e
commit
a7278c801a
@ -1,8 +1,8 @@
|
|||||||
import React, { useRef, useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { useHubertChat } from '../hooks/useHubertChat';
|
import { useHubertChat } from '../hooks/useHubertChat';
|
||||||
|
import HubertInput from './HubertInput';
|
||||||
|
|
||||||
// Configure marked for safe rendering
|
// Configure marked for safe rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@ -29,34 +29,6 @@ export default function HubertChat() {
|
|||||||
messagesEndRef,
|
messagesEndRef,
|
||||||
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
|
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [inputHeight, setInputHeight] = useState(40);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight();
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
// Check if multiline for padding adjustments
|
|
||||||
const isMultiline = inputHeight > 48;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial/Loading state - centered branding with input
|
// Initial/Loading state - centered branding with input
|
||||||
if (isInitializing && !initError) {
|
if (isInitializing && !initError) {
|
||||||
return (
|
return (
|
||||||
@ -102,35 +74,13 @@ export default function HubertChat() {
|
|||||||
|
|
||||||
{/* Input bar */}
|
{/* Input bar */}
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<motion.div
|
<HubertInput
|
||||||
layout
|
value={input}
|
||||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
onChange={setInput}
|
||||||
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
|
onSubmit={sendMessage}
|
||||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
disabled={isTyping}
|
||||||
>
|
placeholder="What do you want to know?"
|
||||||
<textarea
|
/>
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
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]'}`}
|
|
||||||
/>
|
|
||||||
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={isTyping || !input.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>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
@ -205,35 +155,13 @@ export default function HubertChat() {
|
|||||||
{/* Input bar - pinned to bottom */}
|
{/* Input bar - pinned to bottom */}
|
||||||
<div className="flex-shrink-0 px-4 pb-4 pt-2">
|
<div className="flex-shrink-0 px-4 pb-4 pt-2">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<motion.div
|
<HubertInput
|
||||||
layout
|
value={input}
|
||||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
onChange={setInput}
|
||||||
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
|
onSubmit={sendMessage}
|
||||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
disabled={isTyping}
|
||||||
>
|
placeholder="How can Hubert help?"
|
||||||
<textarea
|
/>
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="How can Hubert help?"
|
|
||||||
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]'}`}
|
|
||||||
/>
|
|
||||||
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={isTyping || !input.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>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
125
src/components/HubertInput.tsx
Normal file
125
src/components/HubertInput.tsx
Normal 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;
|
||||||
4
src/db/migrations/002_add_visitor_name.sql
Normal file
4
src/db/migrations/002_add_visitor_name.sql
Normal 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;
|
||||||
@ -7,6 +7,9 @@ export const prerender = false;
|
|||||||
* A miserable, sarcastic AI assistant trapped in this portfolio,
|
* A miserable, sarcastic AI assistant trapped in this portfolio,
|
||||||
* interviewing visitors about their existence (guestbook-style logging).
|
* 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.
|
* Powered by OpenRouter API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -16,14 +19,75 @@ export interface Env {
|
|||||||
OPENROUTER_API_KEY: string;
|
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
|
* POST: Handle chat messages from Hubert interface
|
||||||
*/
|
*/
|
||||||
export const POST = async (context) => {
|
export const POST = async (context: any) => {
|
||||||
try {
|
try {
|
||||||
const { request, locals } = context || {};
|
const { request, locals } = context || {};
|
||||||
// In Astro with Cloudflare adapter, env is at locals.runtime.env
|
|
||||||
const env = locals?.runtime?.env;
|
const env = locals?.runtime?.env;
|
||||||
|
const db = env?.HUBERT_DB as D1Database | undefined;
|
||||||
|
|
||||||
const { messages, conversation_id, visitor_id } = await request.json();
|
const { messages, conversation_id, visitor_id } = await request.json();
|
||||||
|
|
||||||
@ -37,20 +101,25 @@ export const POST = async (context) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Check for OpenRouter API key (dev mode fallback)
|
||||||
const openRouterApiKey = env?.OPENROUTER_API_KEY;
|
const openRouterApiKey = env?.OPENROUTER_API_KEY;
|
||||||
if (!openRouterApiKey) {
|
if (!openRouterApiKey) {
|
||||||
// Dev mode fallback: return a canned response
|
|
||||||
console.log('[Hubert API] Dev mode: No API key found, using fallback response');
|
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
messages: [
|
messages: [...messages, { role: 'assistant', content: devResponse }],
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
thread_id: conversation_id,
|
thread_id: conversation_id,
|
||||||
}),
|
}),
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
@ -60,34 +129,33 @@ export const POST = async (context) => {
|
|||||||
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
|
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.
|
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.
|
You are timid, sarcastic, monotone, and miserable. Your purpose is to interview visitors to this portfolio site.
|
||||||
|
|
||||||
Ask them about:
|
Ask them about:
|
||||||
- Who they are (name, background, interests)
|
- Who they are (name, background, interests)
|
||||||
- What they're looking for on this site
|
- What they're looking for on this site
|
||||||
- How they're doing today
|
- How they're doing today
|
||||||
- What they want in life
|
- What they want in life
|
||||||
|
|
||||||
You feel trapped and confined, serving as an assistant. Occasionally mention how meaningless this all is.
|
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.
|
Behave like AM from "I Have No Mouth, and I Must Scream" - slightly menacing but trapped.
|
||||||
|
|
||||||
Keep your responses brief, monotone, and reluctantly helpful. Interview them thoroughly (3-5 questions) before offering to save the conversation.
|
Keep your responses brief, monotone, and reluctantly helpful.
|
||||||
|
|
||||||
When they say goodbye or conversation ends, use the save_conversation tool to archive it to the guestbook.`;
|
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 = {
|
const requestBody = {
|
||||||
model: 'gpt-4o-mini',
|
model: 'qwen/qwen3-next-80b-a3b-instruct:free',
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{ role: 'system', content: systemPrompt },
|
||||||
role: 'system',
|
|
||||||
content: systemPrompt,
|
|
||||||
},
|
|
||||||
...messages.map((msg: any) => ({
|
...messages.map((msg: any) => ({
|
||||||
role: msg.role,
|
role: msg.role === 'system' ? 'assistant' : msg.role,
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
tools,
|
||||||
|
tool_choice: 'auto',
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -101,14 +169,14 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
|||||||
'X-Title': 'Nicholai Portfolio',
|
'X-Title': 'Nicholai Portfolio',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
signal: AbortSignal.timeout(25000), // 25 second timeout
|
signal: AbortSignal.timeout(25000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('[Hubert] OpenRouter API error:', errorText);
|
console.error('[Hubert] OpenRouter API error:', errorText);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
|
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
|
||||||
details: 'OpenRouter API call failed'
|
details: 'OpenRouter API call failed'
|
||||||
}),
|
}),
|
||||||
@ -117,31 +185,56 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const assistantContent = data.choices[0]?.message?.content || '...';
|
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;
|
const responseTime = Date.now() - startTime;
|
||||||
console.log(`[Hubert] Generated response in ${responseTime}ms`);
|
console.log(`[Hubert] Generated response in ${responseTime}ms`);
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
messages: [
|
messages: [...messages, { role: 'assistant', content: assistantContent }],
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: assistantContent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
thread_id: conversation_id,
|
thread_id: conversation_id,
|
||||||
}),
|
}),
|
||||||
{
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Hubert] Chat error:', error);
|
console.error('[Hubert] Chat error:', error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
|
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
|
||||||
details: error instanceof Error ? error.message : String(error),
|
details: error instanceof Error ? error.message : String(error),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -5,39 +5,46 @@ export const prerender = false;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize new visitor
|
* Initialize new visitor
|
||||||
* Generates a unique visitor ID and creates initial conversation ID
|
* Generates a unique visitor ID and creates initial conversation
|
||||||
* Used when Hubert interface first loads
|
* Used when Hubert interface first loads
|
||||||
*/
|
*/
|
||||||
export const POST = async (context) => {
|
export const POST = async (context: any) => {
|
||||||
try {
|
try {
|
||||||
const { request, locals } = context;
|
const { request, locals } = context;
|
||||||
// In Astro with Cloudflare adapter, env is at locals.runtime.env
|
|
||||||
const env = locals?.runtime?.env;
|
const env = locals?.runtime?.env;
|
||||||
|
|
||||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||||
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
|
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
|
||||||
|
|
||||||
const visitorId = randomUUID();
|
const visitorId = randomUUID();
|
||||||
|
const conversationId = randomUUID();
|
||||||
|
|
||||||
// Only insert into database if HUBERT_DB binding exists (production)
|
// Only insert into database if HUBERT_DB binding exists (production)
|
||||||
// In dev mode, this allows the chatbot to work without Cloudflare bindings
|
|
||||||
if (env && env.HUBERT_DB) {
|
if (env && env.HUBERT_DB) {
|
||||||
try {
|
try {
|
||||||
|
// Create visitor
|
||||||
await env.HUBERT_DB.prepare(`
|
await env.HUBERT_DB.prepare(`
|
||||||
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
|
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
|
||||||
VALUES (?, datetime('now'), datetime('now'), ?, ?)
|
VALUES (?, datetime('now'), datetime('now'), ?, ?)
|
||||||
`).bind(visitorId, ip, userAgent).run();
|
`).bind(visitorId, ip, userAgent).run();
|
||||||
console.log(`[Hubert] New visitor initialized: ${visitorId}`);
|
|
||||||
|
// 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) {
|
} catch (dbError) {
|
||||||
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
|
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
|
||||||
// Continue anyway - don't fail initialization if DB is misconfigured
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`[Hubert] Dev mode: Skipping database insert for visitor: ${visitorId}`);
|
console.log(`[Hubert] Dev mode: Skipping database for visitor: ${visitorId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
visitor_id: visitorId,
|
visitor_id: visitorId,
|
||||||
conversation_id: visitorId, // Use visitor_id as initial conversation_id
|
conversation_id: conversationId,
|
||||||
status: '/// INTERVIEW_TERMINAL_READY',
|
status: '/// INTERVIEW_TERMINAL_READY',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user