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 DOMPurify from 'dompurify';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useHubertChat } from '../hooks/useHubertChat';
|
||||
import HubertInput from './HubertInput';
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
@ -29,34 +29,6 @@ export default function HubertChat() {
|
||||
messagesEndRef,
|
||||
} = 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
|
||||
if (isInitializing && !initError) {
|
||||
return (
|
||||
@ -102,35 +74,13 @@ export default function HubertChat() {
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="w-full max-w-2xl">
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
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'}`}
|
||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
<HubertInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={setInput}
|
||||
onSubmit={sendMessage}
|
||||
disabled={isTyping}
|
||||
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>
|
||||
|
||||
{/* Subtitle */}
|
||||
@ -205,35 +155,13 @@ export default function HubertChat() {
|
||||
{/* Input bar - pinned to bottom */}
|
||||
<div className="flex-shrink-0 px-4 pb-4 pt-2">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
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'}`}
|
||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
<HubertInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={setInput}
|
||||
onSubmit={sendMessage}
|
||||
disabled={isTyping}
|
||||
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>
|
||||
|
||||
|
||||
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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -16,14 +19,75 @@ export interface Env {
|
||||
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) => {
|
||||
export const POST = async (context: any) => {
|
||||
try {
|
||||
const { request, locals } = context || {};
|
||||
// In Astro with Cloudflare adapter, env is at 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();
|
||||
|
||||
@ -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)
|
||||
const openRouterApiKey = env?.OPENROUTER_API_KEY;
|
||||
if (!openRouterApiKey) {
|
||||
// Dev mode fallback: return a canned 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(
|
||||
JSON.stringify({
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.',
|
||||
},
|
||||
],
|
||||
messages: [...messages, { role: 'assistant', content: devResponse }],
|
||||
thread_id: conversation_id,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
@ -72,22 +141,21 @@ Ask them about:
|
||||
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. 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 = {
|
||||
model: 'gpt-4o-mini',
|
||||
model: 'qwen/qwen3-next-80b-a3b-instruct:free',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((msg: any) => ({
|
||||
role: msg.role,
|
||||
role: msg.role === 'system' ? 'assistant' : msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
],
|
||||
tools,
|
||||
tool_choice: 'auto',
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
@ -101,7 +169,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
|
||||
signal: AbortSignal.timeout(25000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -117,26 +185,51 @@ 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 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,
|
||||
},
|
||||
],
|
||||
messages: [...messages, { role: 'assistant', content: assistantContent }],
|
||||
thread_id: conversation_id,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Chat error:', error);
|
||||
|
||||
@ -5,39 +5,46 @@ export const prerender = false;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const POST = async (context) => {
|
||||
export const POST = async (context: any) => {
|
||||
try {
|
||||
const { request, locals } = context;
|
||||
// In Astro with Cloudflare adapter, env is at locals.runtime.env
|
||||
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)
|
||||
// In dev mode, this allows the chatbot to work without Cloudflare bindings
|
||||
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();
|
||||
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) {
|
||||
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
|
||||
// Continue anyway - don't fail initialization if DB is misconfigured
|
||||
}
|
||||
} 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({
|
||||
visitor_id: visitorId,
|
||||
conversation_id: visitorId, // Use visitor_id as initial conversation_id
|
||||
conversation_id: conversationId,
|
||||
status: '/// INTERVIEW_TERMINAL_READY',
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user