255 lines
7.8 KiB
TypeScript
255 lines
7.8 KiB
TypeScript
import type { KeyboardEvent } from 'react';
|
|
import { useRef, useEffect, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { useClient } from 'hooks/useClient';
|
|
import type { ModelOption } from '@/state/useModels';
|
|
import type { useClaude } from './useClaude';
|
|
import { MessageList } from 'widgets/Chat';
|
|
import { InputArea } from './InputArea';
|
|
|
|
export type Attachment =
|
|
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
|
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
|
|
|
type EmbeddableChatProps = {
|
|
chat: ReturnType<typeof useClaude>;
|
|
provider?: 'claude' | 'opencode';
|
|
availableModels?: ModelOption[];
|
|
onProviderChange?: (provider: 'claude' | 'opencode') => void;
|
|
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
|
commandFeedback?: string | null;
|
|
defaultInput?: string;
|
|
className?: string;
|
|
cwd?: { root?: string; path: string };
|
|
autoSend?: boolean;
|
|
};
|
|
|
|
export const EmbeddableChat = ({
|
|
chat,
|
|
provider = 'claude',
|
|
availableModels = [],
|
|
onProviderChange,
|
|
onBeforeSend,
|
|
commandFeedback = null,
|
|
defaultInput = '',
|
|
className,
|
|
cwd,
|
|
autoSend = false,
|
|
}: EmbeddableChatProps) => {
|
|
const {
|
|
messages,
|
|
streamingText,
|
|
isConnected,
|
|
isGenerating,
|
|
sessionId,
|
|
model,
|
|
selectedModel,
|
|
setSelectedModel,
|
|
sendPrompt,
|
|
stopGeneration,
|
|
} = chat;
|
|
|
|
const client = useClient();
|
|
const [input, setInput] = useState(defaultInput);
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
const userScrolledRef = useRef(false);
|
|
|
|
const handleAttachWebpage = async (url: string) => {
|
|
const idx = attachments.length;
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
|
]);
|
|
|
|
try {
|
|
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
|
url,
|
|
sessionId: sessionId ?? undefined,
|
|
provider,
|
|
});
|
|
setAttachments((prev) =>
|
|
prev.map((a, i) =>
|
|
i === idx
|
|
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
|
: a,
|
|
),
|
|
);
|
|
} catch {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
toast.error('Failed to scrape webpage');
|
|
}
|
|
};
|
|
|
|
const handleAttachImage = async (file: File) => {
|
|
const idx = attachments.length;
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
|
]);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
if (sessionId) formData.append('sessionId', sessionId);
|
|
formData.append('provider', provider);
|
|
|
|
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
|
setAttachments((prev) =>
|
|
prev.map((a, i) =>
|
|
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
|
),
|
|
);
|
|
} catch {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
toast.error('Failed to upload image');
|
|
}
|
|
};
|
|
|
|
const handleRemoveAttachment = (index: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
const text = input.trim();
|
|
if (!text || isGenerating) return;
|
|
|
|
if (onBeforeSend) {
|
|
const handled = await onBeforeSend(text);
|
|
if (handled) {
|
|
setInput('');
|
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Prepend attachment content to the prompt
|
|
let prompt = text;
|
|
const ids: string[] = [];
|
|
const images: { filename: string; dataUrl: string }[] = [];
|
|
for (const a of attachments) {
|
|
if (a.loading) continue;
|
|
if (a.type === 'webpage' && a.content) {
|
|
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
|
} else if (a.type === 'image' && a.dataUrl) {
|
|
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
|
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
|
}
|
|
ids.push(a.attachmentId);
|
|
}
|
|
|
|
// On first message (no sessionId), include attachmentIds so server can relocate tmp files
|
|
const cwdForFirst = !sessionId ? cwd : undefined;
|
|
sendPrompt(
|
|
prompt,
|
|
!sessionId && ids.length > 0 ? ids : undefined,
|
|
images.length > 0 ? images : undefined,
|
|
cwdForFirst,
|
|
);
|
|
setAttachments([]);
|
|
setInput('');
|
|
userScrolledRef.current = false;
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
// Auto-resize textarea
|
|
useEffect(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
|
}, [input]);
|
|
|
|
// Auto-scroll to bottom on new messages
|
|
useEffect(() => {
|
|
if (!userScrolledRef.current) {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [messages, streamingText]);
|
|
|
|
// Detect user scrolling up
|
|
useEffect(() => {
|
|
const viewport = scrollViewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
const handleScroll = () => {
|
|
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
|
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
|
userScrolledRef.current = !atBottom;
|
|
setShowJumpToBottom(!atBottom);
|
|
};
|
|
|
|
viewport.addEventListener('scroll', handleScroll);
|
|
return () => viewport.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
const jumpToBottom = () => {
|
|
userScrolledRef.current = false;
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
// Focus textarea on mount
|
|
useEffect(() => {
|
|
textareaRef.current?.focus();
|
|
}, []);
|
|
|
|
// Auto-send first message when autoSend is enabled
|
|
const autoSentRef = useRef(false);
|
|
useEffect(() => {
|
|
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
|
|
autoSentRef.current = true;
|
|
handleSend();
|
|
}
|
|
}, [autoSend, isConnected, messages.length, input]);
|
|
|
|
return (
|
|
<div className={`flex flex-col ${className ?? ''}`}>
|
|
<MessageList
|
|
messages={messages}
|
|
streamingText={streamingText}
|
|
isGenerating={isGenerating}
|
|
showJumpToBottom={showJumpToBottom}
|
|
onJumpToBottom={jumpToBottom}
|
|
onQuestionAnswer={(text) => sendPrompt(text)}
|
|
scrollViewportRef={scrollViewportRef}
|
|
bottomRef={bottomRef}
|
|
/>
|
|
|
|
<InputArea
|
|
input={input}
|
|
onInputChange={setInput}
|
|
onKeyDown={handleKeyDown}
|
|
onSend={handleSend}
|
|
onStop={stopGeneration}
|
|
isGenerating={isGenerating}
|
|
isConnected={isConnected}
|
|
commandFeedback={commandFeedback}
|
|
textareaRef={textareaRef}
|
|
provider={provider}
|
|
messages={messages}
|
|
onProviderChange={onProviderChange}
|
|
availableModels={availableModels}
|
|
selectedModel={selectedModel}
|
|
onModelChange={setSelectedModel}
|
|
model={model}
|
|
attachments={attachments}
|
|
onAttachWebpage={handleAttachWebpage}
|
|
onAttachImage={handleAttachImage}
|
|
onRemoveAttachment={handleRemoveAttachment}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|