Added dictate and add attachments feature
This commit is contained in:
parent
ef3bd22b02
commit
97f40bdc58
2 changed files with 242 additions and 11 deletions
|
|
@ -65,6 +65,15 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Image in message.content (e.g. compare view appends content with image parts)
|
||||
for (const part of message.content ?? []) {
|
||||
if (part.type === "image" && "image" in part) {
|
||||
const encoded = extractImageBase64(part.image);
|
||||
if (encoded) return encoded;
|
||||
}
|
||||
}
|
||||
|
||||
// Image in message.attachments (e.g. chat composer)
|
||||
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
|
||||
for (const attachment of message.attachments ?? []) {
|
||||
for (const part of attachment.content ?? []) {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,103 @@
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAui } from "@assistant-ui/react";
|
||||
import { ArrowUpIcon, SquareIcon } from "lucide-react";
|
||||
import { ArrowUpIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type MutableRefObject,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type CompareMessagePart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; image: string };
|
||||
|
||||
export interface CompareHandle {
|
||||
append: (content: { type: "text"; text: string }[]) => void;
|
||||
append: (content: CompareMessagePart[]) => void;
|
||||
cancel: () => void;
|
||||
isRunning: () => boolean;
|
||||
}
|
||||
|
||||
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
function fileToBase64DataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read image file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function useDictation(
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
|
||||
setText: (value: string | ((prev: string) => string)) => void,
|
||||
) {
|
||||
const [isDictating, setIsDictating] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const SpeechRecognitionAPI =
|
||||
typeof window !== "undefined" &&
|
||||
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
|
||||
if (!SpeechRecognitionAPI) {
|
||||
return;
|
||||
}
|
||||
const recognition = new SpeechRecognitionAPI() as SpeechRecognition;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = "en-US";
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
const last = event.resultIndex;
|
||||
const result = event.results[last];
|
||||
if (!result?.isFinal) return;
|
||||
const transcript = result[0]?.transcript?.trim();
|
||||
if (transcript) {
|
||||
setText((prev) => (prev ? `${prev} ${transcript}` : transcript));
|
||||
}
|
||||
};
|
||||
recognition.onerror = () => {
|
||||
setIsDictating(false);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setIsDictating(false);
|
||||
};
|
||||
recognition.start();
|
||||
recognitionRef.current = recognition;
|
||||
setIsDictating(true);
|
||||
}, [setText]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setIsDictating(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const supported =
|
||||
typeof window !== "undefined" &&
|
||||
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
|
||||
|
||||
return { isDictating, start, stop, supported };
|
||||
}
|
||||
|
||||
export type CompareHandles = MutableRefObject<Record<string, CompareHandle>>;
|
||||
|
||||
const CompareHandlesContext = createContext<CompareHandles | null>(null);
|
||||
|
|
@ -66,6 +144,37 @@ export function RegisterCompareHandle({
|
|||
return null;
|
||||
}
|
||||
|
||||
type PendingImage = { id: string; file: File };
|
||||
|
||||
function PendingImageThumb({
|
||||
file,
|
||||
onRemove,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}): ReactElement {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
||||
return (
|
||||
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
|
||||
<img src={src} alt={file.name} className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-white text-muted-foreground shadow-sm hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SharedComposer({
|
||||
handlesRef,
|
||||
}: {
|
||||
|
|
@ -73,7 +182,15 @@ export function SharedComposer({
|
|||
}): ReactElement {
|
||||
const [text, setText] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
|
||||
textareaRef,
|
||||
setText,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
|
|
@ -84,23 +201,50 @@ export function SharedComposer({
|
|||
return () => clearInterval(id);
|
||||
}, [handlesRef]);
|
||||
|
||||
function send() {
|
||||
const msg = text.trim();
|
||||
if (!msg) {
|
||||
return;
|
||||
const addFiles = useCallback((files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const next: PendingImage[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file?.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
|
||||
if (file.size > MAX_IMAGE_SIZE) continue;
|
||||
next.push({ id: crypto.randomUUID(), file });
|
||||
}
|
||||
setPendingImages((prev) => [...prev, ...next]);
|
||||
}, []);
|
||||
|
||||
const removePendingImage = useCallback((id: string) => {
|
||||
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
async function send() {
|
||||
const msg = text.trim();
|
||||
if (!msg && pendingImages.length === 0) return;
|
||||
|
||||
const content: CompareMessagePart[] = [];
|
||||
for (const { file } of pendingImages) {
|
||||
try {
|
||||
const image = await fileToBase64DataURL(file);
|
||||
content.push({ type: "image", image });
|
||||
} catch {
|
||||
// skip failed image
|
||||
}
|
||||
}
|
||||
if (msg) {
|
||||
content.push({ type: "text", text: msg });
|
||||
}
|
||||
if (content.length === 0) return;
|
||||
|
||||
const content: { type: "text"; text: string }[] = [
|
||||
{ type: "text", text: msg },
|
||||
];
|
||||
for (const handle of Object.values(handlesRef.current)) {
|
||||
handle.append(content);
|
||||
}
|
||||
setText("");
|
||||
setPendingImages([]);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (isDictating) stopDictation();
|
||||
for (const handle of Object.values(handlesRef.current)) {
|
||||
handle.cancel();
|
||||
}
|
||||
|
|
@ -115,8 +259,33 @@ export function SharedComposer({
|
|||
}
|
||||
}
|
||||
|
||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0) && !running;
|
||||
|
||||
return (
|
||||
<div className="shadow-border ring-1 ring-border relative flex w-full flex-col rounded-2xl bg-background px-1 pt-2 transition-shadow">
|
||||
<div
|
||||
className={`shadow-border ring-1 ring-border relative flex w-full flex-col rounded-2xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "ring-ring bg-accent/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
|
||||
{pendingImages.map(({ id, file }) => (
|
||||
<PendingImageThumb
|
||||
key={id}
|
||||
file={file}
|
||||
onRemove={() => removePendingImage(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
|
|
@ -126,7 +295,60 @@ export function SharedComposer({
|
|||
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="relative mx-2 mb-2 flex items-center justify-end">
|
||||
<div className="relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<TooltipIconButton
|
||||
tooltip="Add attachment"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Add attachment"
|
||||
>
|
||||
<PlusIcon className="size-5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
{dictationSupported && (
|
||||
<>
|
||||
{!isDictating ? (
|
||||
<TooltipIconButton
|
||||
tooltip="Dictate"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
|
||||
onClick={startDictation}
|
||||
aria-label="Dictate"
|
||||
>
|
||||
<MicIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
) : (
|
||||
<TooltipIconButton
|
||||
tooltip="Stop dictation"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-destructive"
|
||||
onClick={stopDictation}
|
||||
aria-label="Stop dictation"
|
||||
>
|
||||
<SquareIcon className="size-3 animate-pulse fill-current" />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -145,7 +367,7 @@ export function SharedComposer({
|
|||
size="icon"
|
||||
className="size-8 rounded-full"
|
||||
onClick={send}
|
||||
disabled={!text.trim()}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue