Chat refactoring
This commit is contained in:
@@ -10,8 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { Card } from '@/components/Card';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
|
||||
export const ChatList = () => {
|
||||
const { sessions } = useChatSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No sessions yet. Start a new chat!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 p-3">
|
||||
{sessions.map((session) => (
|
||||
<a
|
||||
key={session.id}
|
||||
href={`/chat/${session.id}`}
|
||||
className="block p-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 hover:bg-duck-teal/5 dark:hover:bg-duck-teal/10 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
{session.model && (
|
||||
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
|
||||
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||
import { SessionBar } from 'apps/ChatHistory';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { usePi } from './usePi';
|
||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||
import { EmbeddableChat, type UsePiType, type Attachment } from 'apps/Chat';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
export type { Attachment };
|
||||
|
||||
type ChatPanelProps = {
|
||||
chat: ReturnType<typeof usePi>;
|
||||
availableModels?: ModelOption[];
|
||||
chat: UsePiType;
|
||||
};
|
||||
|
||||
export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
||||
export const ChatPanel = ({ chat }: ChatPanelProps) => {
|
||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const initialSentRef = useRef(false);
|
||||
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const slashCommands = useSlashCommands({ sessionId });
|
||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||
const listPath = '/chat';
|
||||
|
||||
// Capture prefill input from location.state (one-time, before first render completes)
|
||||
// Capture initial state from navigation
|
||||
const locationState = location.state as {
|
||||
initialMessage?: string;
|
||||
prefillInput?: string;
|
||||
@@ -38,42 +31,30 @@ export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
||||
attachmentIds?: string[];
|
||||
images?: { filename: string; dataUrl: string }[];
|
||||
} | null;
|
||||
const initialPrefill = useRef(locationState?.prefillInput ?? '');
|
||||
|
||||
const handleBeforeSend = async (text: string) => {
|
||||
if (text.startsWith('/')) {
|
||||
const result = await slashCommands.execute(text);
|
||||
if (result.handled) {
|
||||
setCommandFeedback(result.feedback);
|
||||
return true;
|
||||
|
||||
const initialMessage = locationState?.initialMessage
|
||||
? {
|
||||
text: locationState.initialMessage,
|
||||
attachmentIds: locationState.attachmentIds,
|
||||
images: locationState.images,
|
||||
cwd: locationState.cwd,
|
||||
}
|
||||
}
|
||||
setCommandFeedback(null);
|
||||
return false;
|
||||
};
|
||||
: undefined;
|
||||
|
||||
const defaultInput = locationState?.prefillInput ?? '';
|
||||
const initialModel = locationState?.model ?? null;
|
||||
|
||||
// Auto-send initial message from Home launcher
|
||||
// Clear location state after capturing
|
||||
useEffect(() => {
|
||||
const state = location.state as typeof locationState;
|
||||
if (!state || initialSentRef.current) return;
|
||||
if (state.prefillInput) {
|
||||
initialSentRef.current = true;
|
||||
if (locationState) {
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
if (!state.initialMessage || !isConnected) return;
|
||||
initialSentRef.current = true;
|
||||
if (state.model) setSelectedModel(state.model);
|
||||
sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd);
|
||||
// Clear the location state so refresh doesn't re-send
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}, [isConnected, location.state]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${
|
||||
fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
||||
}`}
|
||||
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
||||
}`}
|
||||
>
|
||||
<SessionBar
|
||||
listPath={listPath}
|
||||
@@ -90,11 +71,10 @@ export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
||||
/>
|
||||
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
availableModels={availableModels}
|
||||
onBeforeSend={handleBeforeSend}
|
||||
commandFeedback={commandFeedback}
|
||||
defaultInput={initialPrefill.current}
|
||||
sessionId={sessionId ?? undefined}
|
||||
initialModel={initialModel}
|
||||
initialMessage={initialMessage}
|
||||
defaultInput={defaultInput}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
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 { usePi } from './usePi';
|
||||
import { MessageList } from 'apps/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 usePi>;
|
||||
availableModels?: ModelOption[];
|
||||
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
||||
commandFeedback?: string | null;
|
||||
defaultInput?: string;
|
||||
promptPrefix?: string;
|
||||
className?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
autoSend?: boolean;
|
||||
};
|
||||
|
||||
export const EmbeddableChat = ({
|
||||
chat,
|
||||
availableModels = [],
|
||||
onBeforeSend,
|
||||
commandFeedback = null,
|
||||
defaultInput = '',
|
||||
promptPrefix,
|
||||
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: 'pi-mono',
|
||||
});
|
||||
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', 'pi-mono');
|
||||
|
||||
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 metadata/attachment content to the prompt
|
||||
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : 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}
|
||||
messages={messages}
|
||||
availableModels={availableModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
model={model}
|
||||
attachments={attachments}
|
||||
onAttachWebpage={handleAttachWebpage}
|
||||
onAttachImage={handleAttachImage}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,355 +0,0 @@
|
||||
import type { KeyboardEvent, RefObject } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { FileText, Image, Link, Loader2, Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import type { Attachment } from './EmbeddableChat';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||
const ctx = new AudioContext();
|
||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||
await ctx.close();
|
||||
|
||||
const samples = buf.getChannelData(0);
|
||||
const len = samples.length;
|
||||
const sr = buf.sampleRate;
|
||||
const ab = new ArrayBuffer(44 + len * 2);
|
||||
const v = new DataView(ab);
|
||||
|
||||
const s = (o: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
|
||||
};
|
||||
s(0, 'RIFF');
|
||||
v.setUint32(4, 36 + len * 2, true);
|
||||
s(8, 'WAVE');
|
||||
s(12, 'fmt ');
|
||||
v.setUint32(16, 16, true);
|
||||
v.setUint16(20, 1, true);
|
||||
v.setUint16(22, 1, true);
|
||||
v.setUint32(24, sr, true);
|
||||
v.setUint32(28, sr * 2, true);
|
||||
v.setUint16(32, 2, true);
|
||||
v.setUint16(34, 16, true);
|
||||
s(36, 'data');
|
||||
v.setUint32(40, len * 2, true);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const val = Math.max(-1, Math.min(1, samples[i]!));
|
||||
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
|
||||
}
|
||||
|
||||
return new Blob([ab], { type: 'audio/wav' });
|
||||
};
|
||||
|
||||
type InputAreaProps = {
|
||||
input: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (ev: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
isGenerating: boolean;
|
||||
isConnected: boolean;
|
||||
commandFeedback: string | null;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
messages: ChatMessage[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
model: string | null;
|
||||
attachments: Attachment[];
|
||||
onAttachWebpage: (url: string) => void;
|
||||
onAttachImage: (file: File) => void;
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
};
|
||||
|
||||
export const InputArea = ({
|
||||
input,
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
onStop,
|
||||
isGenerating,
|
||||
isConnected,
|
||||
commandFeedback,
|
||||
textareaRef,
|
||||
messages,
|
||||
availableModels,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
model,
|
||||
attachments,
|
||||
onAttachWebpage,
|
||||
onAttachImage,
|
||||
onRemoveAttachment,
|
||||
}: InputAreaProps) => {
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const handleUrlSubmit = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
onAttachWebpage(url);
|
||||
setUrlInput('');
|
||||
setUrlDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleMicClick = async () => {
|
||||
if (recording) {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder) return;
|
||||
|
||||
setRecording(false);
|
||||
|
||||
try {
|
||||
if (recorder.state === 'inactive') {
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
|
||||
recorder.onstop = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
|
||||
chunksRef.current = [];
|
||||
};
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
|
||||
if (blob.size === 0) {
|
||||
toast.error('No audio was captured');
|
||||
return;
|
||||
}
|
||||
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const wav = await blobToWav(blob);
|
||||
const formData = new FormData();
|
||||
formData.append('file', wav, 'recording.wav');
|
||||
formData.append('temperature', '0.0');
|
||||
formData.append('temperature_inc', '0.2');
|
||||
formData.append('response_format', 'json');
|
||||
|
||||
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
const text = (json.text ?? '').trim();
|
||||
if (text) onInputChange(input + (input.length > 0 ? ' ' : '') + text);
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
recorder.stream?.getTracks().forEach((t) => t.stop());
|
||||
chunksRef.current = [];
|
||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||
};
|
||||
|
||||
recorder.start(250);
|
||||
setRecording(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
|
||||
{commandFeedback && (
|
||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<Link className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAttachment(i)}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-1 md:gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[800]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
type="button"
|
||||
disabled={transcribing}
|
||||
onClick={handleMicClick}
|
||||
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{transcribing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : recording ? (
|
||||
<>
|
||||
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
|
||||
<Square className="h-3.5 w-3.5 text-red-500" />
|
||||
</>
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) onAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => onInputChange(ev.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) onAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Settings
|
||||
messages={messages}
|
||||
availableModels={availableModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
model={model}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
|
||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlSubmit}
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import { useRecentModels } from '@/state/useRecentModels';
|
||||
|
||||
type OpenCodeModelPickerProps = {
|
||||
models: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onSelect: (modelId: string) => void;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
|
||||
export const OpenCodeModelPicker = ({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
}: OpenCodeModelPickerProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { recents, addRecent } = useRecentModels();
|
||||
|
||||
const selected = models.find((m) => m.id === selectedModel);
|
||||
|
||||
const groupedByProvider = useMemo(() => {
|
||||
const groups: Record<string, ModelOption[]> = {};
|
||||
for (const m of models) {
|
||||
const provider = m.provider ?? 'Other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push(m);
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, items]) => ({
|
||||
provider,
|
||||
models: items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
onSelect(model.id);
|
||||
addRecent(model);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={isGenerating || !isConnected}
|
||||
className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer hover:bg-transparent hover:text-duck-dark/70"
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
{selected.name}
|
||||
{selected.provider && <span className="hidden md:inline"> ({selected.provider})</span>}
|
||||
</>
|
||||
) : (
|
||||
'select model'
|
||||
)}
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[800] w-[320px] p-0" align="end" side="top">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList className="max-h-[400px]">
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{recents.length > 0 && (
|
||||
<CommandGroup heading="Recent">
|
||||
{recents.map((m) => (
|
||||
<CommandItem
|
||||
key={`recent-${m.id}`}
|
||||
value={`${m.name} ${m.provider ?? ''}`}
|
||||
onSelect={() => handleSelect(m.id)}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate font-bold">{m.name}</span>
|
||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{groupedByProvider.map(({ provider, models: providerModels }) => (
|
||||
<CommandGroup key={provider} heading={provider}>
|
||||
{providerModels.map((m) => (
|
||||
<CommandItem key={m.id} value={`${m.name} ${m.provider ?? ''}`} onSelect={() => handleSelect(m.id)}>
|
||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate font-bold">{m.name}</span>
|
||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
opencode: 'OpenCode Zen',
|
||||
google: 'Google',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
xai: 'xAI',
|
||||
openrouter: 'OpenRouter',
|
||||
huggingface: 'Hugging Face',
|
||||
'github-copilot': 'GitHub Copilot',
|
||||
minimax: 'MiniMax',
|
||||
bedrock: 'Amazon Bedrock',
|
||||
'google-vertex': 'Google Vertex AI',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
};
|
||||
|
||||
type SettingsProps = {
|
||||
messages: ChatMessage[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
model: string | null;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
|
||||
export const Settings = ({
|
||||
messages,
|
||||
availableModels,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
model,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
}: SettingsProps) => {
|
||||
const providers = useMemo(
|
||||
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
|
||||
[availableModels],
|
||||
);
|
||||
|
||||
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
|
||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||
const fallbackModelId = providerModels[0]?.id ?? null;
|
||||
|
||||
const handleProviderClick = (provider: string) => {
|
||||
const firstModel = availableModels.find((m) => m.provider === provider);
|
||||
if (firstModel) onModelChange(firstModel.id);
|
||||
};
|
||||
|
||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{messages.length > 0 ? (
|
||||
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
|
||||
{activeProvider ? displayName(activeProvider) : 'Pi'}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleProviderClick(provider)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
activeProvider === provider
|
||||
? 'bg-background text-duck-dark shadow-sm'
|
||||
: 'text-duck-dark/70 hover:text-duck-dark/90'
|
||||
}`}
|
||||
>
|
||||
{displayName(provider)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-duck-dark/50">
|
||||
{providerModels.length > 0 ? (
|
||||
<Select
|
||||
value={selectedModel ?? fallbackModelId ?? undefined}
|
||||
onValueChange={(v) => onModelChange(v)}
|
||||
disabled={isGenerating || !isConnected}
|
||||
>
|
||||
<SelectTrigger className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[800]" side="top">
|
||||
{providerModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span>{model ?? 'Pi'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,3 @@
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
export { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||
export { InputArea } from './InputArea';
|
||||
export { Settings } from './Settings';
|
||||
export { usePi } from './usePi';
|
||||
export { ChatList } from './ChatList';
|
||||
export { EmbeddableChat, usePi, ChatList } from 'apps/Chat';
|
||||
export type { Attachment } from 'apps/Chat';
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo, Message } from 'apps/Chat';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
type ResourceChatStorage = {
|
||||
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
|
||||
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type UsePiOptions = {
|
||||
replaceUrl?: boolean;
|
||||
storage?: ResourceChatStorage;
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export function usePi(initialSessionId?: string, initialModel?: string | null, options?: UsePiOptions) {
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const { getSession, saveMessages } = useChatSessions();
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/pi/chat/ws?token=${token}`;
|
||||
|
||||
function flushStreaming() {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
|
||||
function commitStreaming() {
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
}
|
||||
|
||||
function handleMessage(data: unknown) {
|
||||
const msg = data as ServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
setCwd(msg.cwd);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
streamingRef.current += msg.text;
|
||||
flushStreaming();
|
||||
break;
|
||||
|
||||
case 'assistant:text':
|
||||
if (streamingRef.current) {
|
||||
commitStreaming();
|
||||
} else {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool:start':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'tool',
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
toolCallId: msg.toolCallId,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolCallId === msg.toolCallId
|
||||
? { ...m, output: msg.output, isError: msg.isError }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
cost: msg.cost,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'sync:messages':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
// Convert Message[] to ChatMessage[]
|
||||
const chatMessages = msg.messages.map((m): ChatMessage => {
|
||||
if (m.role === 'user') {
|
||||
return { role: 'user', text: m.text || '' };
|
||||
} else if (m.role === 'assistant') {
|
||||
return { role: 'assistant', text: m.text || '' };
|
||||
} else if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool',
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant', text: '' }; // Fallback
|
||||
});
|
||||
setMessages(chatMessages);
|
||||
setIsGenerating(msg.isGenerating);
|
||||
if (msg.streamingText) {
|
||||
streamingRef.current = msg.streamingText;
|
||||
flushStreaming();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
commitStreaming();
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
|
||||
|
||||
// Load messages from server on mount when resuming a session
|
||||
useEffect(() => {
|
||||
if (storage) {
|
||||
storage
|
||||
.load()
|
||||
.then(({ sessionId: sid, messages: msgs }) => {
|
||||
if (sid) {
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
if (msgs.length > 0) setMessages(msgs);
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!initialSessionId) return;
|
||||
getSession(initialSessionId)
|
||||
.then((data) => {
|
||||
if (data.session?.messages && data.session.messages.length > 0) {
|
||||
// Convert backend Message[] to ChatMessage[]
|
||||
const chatMessages = data.session.messages.map((m: Message): ChatMessage => {
|
||||
if (m.role === 'user') {
|
||||
return { role: 'user', text: m.text || '' };
|
||||
} else if (m.role === 'assistant') {
|
||||
return { role: 'assistant', text: m.text || '' };
|
||||
} else if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool',
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant', text: '' }; // Fallback
|
||||
});
|
||||
setMessages(chatMessages);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [initialSessionId]);
|
||||
|
||||
// Debounced save messages to server
|
||||
useEffect(() => {
|
||||
if (!sessionIdRef.current || messages.length === 0) return;
|
||||
|
||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||
|
||||
const sid = sessionIdRef.current;
|
||||
const snapshot = messages;
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
if (storage) {
|
||||
storage.save(sid, snapshot).catch(() => {});
|
||||
} else {
|
||||
saveMessages(sid, snapshot).catch(() => {});
|
||||
}
|
||||
saveTimerRef.current = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function sendPrompt(
|
||||
text: string,
|
||||
attachmentIds?: string[],
|
||||
images?: { filename: string; dataUrl: string }[],
|
||||
cwdParam?: { root?: string; path: string },
|
||||
groupSlug?: string | null,
|
||||
) {
|
||||
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
|
||||
setIsGenerating(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
|
||||
// Parse dataUrls into { mediaType, data } for the server
|
||||
const imageData = images
|
||||
?.map((img) => {
|
||||
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
return match ? { mediaType: match[1], data: match[2] } : null;
|
||||
})
|
||||
.filter((x): x is { mediaType: string; data: string } => x !== null);
|
||||
|
||||
send({
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
sessionId: sessionIdRef.current,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||
...(groupSlug !== undefined ? { groupSlug } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(resourceChatDir ? { resourceChatDir } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
send({ type: 'stop' });
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
cwd,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||
|
||||
export type SelectedSession = {
|
||||
id: string;
|
||||
@@ -66,12 +64,14 @@ type SessionChatProps = {
|
||||
};
|
||||
|
||||
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
const chat = usePi(sessionId, model, { replaceUrl: false });
|
||||
const models = useVisiblePiModels();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
|
||||
// We need connection status for the DetailBar, so we still call usePi here
|
||||
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
|
||||
const chat = usePi(sessionId, model, { replaceUrl: false });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
@@ -84,7 +84,11 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
/>
|
||||
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
||||
<EmbeddableChat
|
||||
sessionId={sessionId}
|
||||
initialModel={model ?? undefined}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,30 +96,25 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
function NewChat() {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const initialSentRef = useRef(false);
|
||||
const chat = usePi();
|
||||
const models = useVisiblePiModels();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
// We need connection status for DetailBar, so call usePi
|
||||
const chat = usePi();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.sessionId) {
|
||||
setSelected({ id: chat.sessionId, model: chat.model });
|
||||
}
|
||||
}, [chat.sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
||||
if (locationState.prefillInput) {
|
||||
initialSentRef.current = true;
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
if (!locationState.initialMessage) return;
|
||||
initialSentRef.current = true;
|
||||
if (locationState.model) chat.setSelectedModel(locationState.model);
|
||||
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd);
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}, [chat.isConnected, location.state]);
|
||||
const initialMessage = locationState?.initialMessage
|
||||
? {
|
||||
text: locationState.initialMessage,
|
||||
attachmentIds: locationState.attachmentIds,
|
||||
images: locationState.images,
|
||||
cwd: locationState.cwd,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -126,8 +125,9 @@ function NewChat() {
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
availableModels={models}
|
||||
sessionId={undefined}
|
||||
initialModel={locationState?.model ?? undefined}
|
||||
initialMessage={initialMessage}
|
||||
defaultInput={locationState?.prefillInput ?? ''}
|
||||
cwd={locationState?.cwd}
|
||||
className="flex-1 min-h-0"
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { TaskInfo } from 'apps/Chat';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskSummary } from 'apps/FileBrowser';
|
||||
|
||||
@@ -1,357 +1,44 @@
|
||||
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Send,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Paperclip,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
X,
|
||||
FileText,
|
||||
Image,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
opencode: 'OpenCode Zen',
|
||||
google: 'Google',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
xai: 'xAI',
|
||||
openrouter: 'OpenRouter',
|
||||
huggingface: 'Hugging Face',
|
||||
'github-copilot': 'GitHub Copilot',
|
||||
minimax: 'MiniMax',
|
||||
bedrock: 'Amazon Bedrock',
|
||||
'google-vertex': 'Google Vertex AI',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
};
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatLauncher as ChatLauncherComponent } from 'apps/Chat';
|
||||
|
||||
export const ChatLauncher = () => {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const piModels = useVisiblePiModels();
|
||||
|
||||
const client = useClient();
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(settings.chat.defaultModel);
|
||||
}, [settings.chat.defaultModel]);
|
||||
|
||||
const providers = useMemo(
|
||||
() => [...new Set(piModels.map((m: ModelOption) => m.provider).filter(Boolean))] as string[],
|
||||
[piModels],
|
||||
);
|
||||
|
||||
const activeProvider = piModels.find((m: ModelOption) => m.id === model)?.provider ?? providers[0];
|
||||
const providerModels = piModels.filter((m: ModelOption) => m.provider === activeProvider);
|
||||
|
||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
||||
|
||||
const handleProviderClick = (provider: string) => {
|
||||
const firstModel = piModels.find((m: ModelOption) => m.provider === provider);
|
||||
if (firstModel) setModel(firstModel.id);
|
||||
};
|
||||
|
||||
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,
|
||||
provider: 'pi-mono',
|
||||
});
|
||||
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);
|
||||
formData.append('provider', 'pi-mono');
|
||||
|
||||
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 handleUrlSubmit = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
handleAttachWebpage(url);
|
||||
setUrlInput('');
|
||||
setUrlDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
|
||||
let prompt = text;
|
||||
const attachmentIds: 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 });
|
||||
}
|
||||
attachmentIds.push(a.attachmentId);
|
||||
}
|
||||
|
||||
const handleSubmit = (data: {
|
||||
prompt: string;
|
||||
model: string | null;
|
||||
attachmentIds?: string[];
|
||||
images?: { filename: string; dataUrl: string }[];
|
||||
}) => {
|
||||
navigate('/chat/new', {
|
||||
state: {
|
||||
initialMessage: prompt,
|
||||
model,
|
||||
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
initialMessage: data.prompt,
|
||||
model: data.model,
|
||||
attachmentIds: data.attachmentIds,
|
||||
images: data.images,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget title="Start Chat">
|
||||
<div className="p-4 pb-2 pt-1">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[600]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) handleAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) handleAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="What do you want to work on now?"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
size="icon"
|
||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleProviderClick(provider)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
activeProvider === provider
|
||||
? 'bg-background text-duck-dark shadow-sm'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{displayName(provider)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{providerModels.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
|
||||
{providerModels.find((m) => m.id === (model ?? providerModels[0]?.id))?.name ?? providerModels[0]?.name}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
|
||||
{providerModels.map((m) => (
|
||||
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
|
||||
<Check
|
||||
className={`mr-2 h-3 w-3 ${(model ?? providerModels[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
|
||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlSubmit}
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<Widget title="Start Chat">
|
||||
<ChatLauncherComponent
|
||||
availableModels={piModels}
|
||||
selectedModel={model}
|
||||
onModelChange={setModel}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TerminalView } from 'apps/Terminal';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||
import { usePi } from '../Chat/usePi';
|
||||
import { usePi } from 'apps/Chat';
|
||||
import { ChatPanel } from '../Chat/ChatPanel';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
|
||||
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
||||
|
||||
type UseSlashCommandsParams = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
||||
const { renameSession } = useChatSessions();
|
||||
|
||||
const execute = async (input: string): Promise<SlashCommandResult> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return { handled: false };
|
||||
|
||||
const spaceIndex = trimmed.indexOf(' ');
|
||||
const command = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
||||
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
switch (command) {
|
||||
case 'rename':
|
||||
if (!sessionId || !args) return { handled: false };
|
||||
await renameSession(sessionId, args);
|
||||
return { handled: true, feedback: `Session renamed to "${args}"` };
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
};
|
||||
|
||||
return { execute };
|
||||
};
|
||||
Reference in New Issue
Block a user