import { At, ArrowUp, ArrowClockwise, CaretDown, Check, CheckCircle, Circle, Lightbulb, ListChecks, Microphone, MicrophoneSlash, Paperclip, PencilSimple, Stop, Trash, WarningCircle, X, } from "@phosphor-icons/react"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { cn } from "../../lib/cn"; import { fieldNote, matchesAcceptedFileTypes, UploadPreviewChip, type FieldMeta, type UploadPreviewItem, } from "./shared"; export type AIInputCapability = { description?: string; label: string; value: string; }; export type AIInputSubmitPayload = { capability?: string; files: File[]; model?: string; prompt: string; task?: string; }; export type AIInputModel = { description?: string; label: string; value: string; }; export type AIInputMention = { description?: string; icon?: ReactNode; id: string; kind?: string; label: string; trigger?: string; value?: string; }; export type AIInputTask = { detail?: string; description?: string; icon?: ReactNode; id: string; label?: string; status?: "pending" | "in_progress" | "completed" | "failed"; title?: string; }; export type AIInputQueueItem = { capability?: string; id: string; label?: string; model?: string; prompt: string; status?: "queued" | "steering"; }; export type AIInputQuestionOption = { description?: string; id: string; label: string; }; export type AIInputQuestionType = "single-choice" | "multi-choice" | "long-text" | "text"; export type AIInputQuestion = { allowCustomAnswer?: boolean; description?: string; id: string; otherLabel?: string; options?: AIInputQuestionOption[]; prompt: string; type?: AIInputQuestionType; }; export type AIInputQuestionAnswer = { questionId: string; selectedOptions?: AIInputQuestionOption[]; type: AIInputQuestionType; value: string | string[]; }; export type AIInputCompactAction = { disabled?: boolean; label: string; leadingIcon?: ReactNode; onClick: () => void; }; type SpeechRecognitionLike = { abort: () => void; onend: (() => void) | null; onerror: ((event: { error?: string }) => void) | null; onresult: ((event: { results: ArrayLike> }) => void) | null; start: () => void; }; export type AIInputProps = FieldMeta & { acceptedFileTypes?: string[]; capabilities?: AIInputCapability[]; className?: string; defaultCapability?: string; defaultValue?: string; disabled?: boolean; loading?: boolean; maxFiles?: number; maxRows?: number; mainTrailingAction?: AIInputCompactAction; showAttachments?: boolean; mentions?: AIInputMention[]; models?: AIInputModel[]; minRows?: number; model?: string; multiple?: boolean; onCapabilityChange?: (value: string) => void; onChange?: (value: string) => void; onMentionSearch?: (query: string, trigger: string) => AIInputMention[]; onMentionSelect?: (mention: AIInputMention) => void; mentionHighlightClassName?: string; renderMention?: (mention: AIInputMention, token: string) => ReactNode; onModelChange?: (value: string) => void; onNarrationCompleted?: (text: string) => void; onTap?: () => void; onQueueItemRemove?: (item: AIInputQueueItem) => void; onQueueItemEdit?: (item: AIInputQueueItem) => void; onQueueItemSelect?: (item: AIInputQueueItem) => void; onQueueSubmit?: (payload: AIInputSubmitPayload) => void; onForceSteer?: (payload: AIInputSubmitPayload) => void; onQuestionAnswer?: (answer: AIInputQuestionAnswer, question: AIInputQuestion) => void; onQuestionDismiss?: (question: AIInputQuestion) => void; onStop?: () => void; onRetry?: () => void; queue?: AIInputQueueItem[]; queueEnabled?: boolean; onSubmit?: (payload: AIInputSubmitPayload) => void; tasks?: AIInputTask[]; defaultTask?: string; onTaskChange?: (value: string) => void; onVoiceClick?: () => void; onVoiceTranscript?: (transcript: string) => void; placeholder?: string; preTrailingActions?: AIInputCompactAction[]; showCapabilitySelector?: boolean; showClear?: boolean; showMentions?: boolean; showModels?: boolean; showQueue?: boolean; showTaskProgress?: boolean; showTasks?: boolean; showVoiceButton?: boolean; question?: AIInputQuestion; showMarkdownPreview?: boolean; streamError?: string; voiceToText?: boolean; submitLabel?: string; value?: string; variant?: "default" | "compact"; }; function clampRows(value: number | undefined, fallback: number, minimum: number) { return Math.max(minimum, Math.round(value ?? fallback)); } function isTaskProgressItem(task: AIInputTask) { return Boolean(task.title || task.detail || task.status); } function taskStatusLabel(status: AIInputTask["status"]) { if (status === "in_progress") return "In progress"; if (status === "completed") return "Complete"; if (status === "failed") return "Failed"; return "Pending"; } function TaskProgressList({ tasks, expanded, onToggle, }: { tasks: AIInputTask[]; expanded: boolean; onToggle: () => void; }) { if (tasks.length === 0) return null; const completeCount = tasks.filter((task) => task.status === "completed").length; return (
{expanded ? (
{tasks.map((task) => { const status = task.status ?? "pending"; const title = task.title ?? task.label ?? task.id; const detail = task.detail ?? task.description; const StatusIcon = status === "completed" ? CheckCircle : status === "failed" ? WarningCircle : status === "in_progress" ? ArrowClockwise : Circle; return (
); })}
) : null}
); } function renderHighlightedPrompt( value: string, mentions: AIInputMention[], className: string, renderMention?: (mention: AIInputMention, token: string) => ReactNode, ) { const tokens = /(^|\s)([@#/])([\w-]+)/g; const output: ReactNode[] = []; let cursor = 0; let match: RegExpExecArray | null; let index = 0; while ((match = tokens.exec(value))) { const prefix = match[1] ?? ""; const trigger = match[2] ?? ""; const token = match[3] ?? ""; const mention = mentions.find((item) => (item.trigger ?? trigger) === trigger && (item.value ?? item.label).toLowerCase() === token.toLowerCase(), ); if (!mention) continue; const start = match.index; output.push(value.slice(cursor, start)); output.push(prefix); output.push( renderMention?.(mention, `${trigger}${token}`) ?? ( {trigger}{token} ), ); cursor = start + match[0].length; index += 1; } output.push(value.slice(cursor)); return output; } function renderInlinePrompt(value: string, mentions: AIInputMention[], mentionClassName: string, renderMention?: (mention: AIInputMention, token: string) => ReactNode) { const parts = value.split(/(\*\*[^*]+\*\*|`[^`]+`|[@#/][\w-]+)/g).filter(Boolean); return parts.map((part, index) => { if (part.startsWith("**") && part.endsWith("**")) return {part.slice(2, -2)}; if (part.startsWith("`") && part.endsWith("`")) return {part.slice(1, -1)}; const match = part.match(/^([@#/])([\w-]+)$/); const mention = match ? mentions.find((item) => (item.trigger ?? match[1]) === match[1] && (item.value ?? item.label).toLowerCase() === match[2].toLowerCase()) : undefined; if (mention && match) return renderMention?.(mention, part) ?? {part}; return part; }); } export function AIInput({ acceptedFileTypes, capabilities = [ { label: "Ask", value: "ask" }, { label: "Summarize", value: "summarize" }, { label: "Extract", value: "extract" }, ], className, defaultCapability, defaultValue, disabled, error, hint, label, loading, mainTrailingAction, maxFiles = 6, maxRows = 6, showAttachments = true, mentions = [], models = [ { label: "Auto", value: "auto", description: "Uhuru chooses the best available model." }, { label: "Fast", value: "fast", description: "Prioritizes speed for simple tasks." }, { label: "Quality", value: "quality", description: "Prioritizes deeper reasoning and quality." }, ], minRows = 2, model, multiple = true, onCapabilityChange, onChange, onMentionSearch, onMentionSelect, mentionHighlightClassName = "", renderMention, onModelChange, onNarrationCompleted, onTap, onQueueItemRemove, onQueueItemEdit, onQueueItemSelect, onQueueSubmit, onForceSteer, onQuestionAnswer, onQuestionDismiss, onRetry, onStop, onSubmit, queue = [], queueEnabled = false, tasks = [], defaultTask, onTaskChange, onVoiceClick, onVoiceTranscript, placeholder = "Ask Uhuru AI anything...", preTrailingActions = [], showCapabilitySelector = false, showClear = true, showMentions = true, showModels = true, showQueue = true, showTaskProgress = true, showTasks = true, showVoiceButton = false, question, showMarkdownPreview = false, streamError, submitLabel = "Send", value, variant = "default", voiceToText = false, }: AIInputProps) { const generatedId = useId(); const fieldId = `${generatedId}-prompt`; const hintId = hint ? `${generatedId}-hint` : undefined; const errorId = `${generatedId}-error`; const inputRef = useRef(null); const textareaRef = useRef(null); const highlightRef = useRef(null); const popoverRootRef = useRef(null); const filesRef = useRef([]); const controlledValue = value; const [internalValue, setInternalValue] = useState(defaultValue ?? ""); const [dragActive, setDragActive] = useState(false); const [selectedCapability, setSelectedCapability] = useState( defaultCapability ?? capabilities[0]?.value ?? "", ); const [selectedModel, setSelectedModel] = useState(model ?? "auto"); const [selectedTask, setSelectedTask] = useState(defaultTask ?? tasks[0]?.id ?? ""); const [capabilityOpen, setCapabilityOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false); const [taskOpen, setTaskOpen] = useState(false); const [voiceActive, setVoiceActive] = useState(false); const [voiceError, setVoiceError] = useState(); const [dismissedError, setDismissedError] = useState(); const [dismissedStreamError, setDismissedStreamError] = useState(); const recognitionRef = useRef(null); const [mentionMatches, setMentionMatches] = useState([]); const [mentionOpen, setMentionOpen] = useState(false); const [mentionIndex, setMentionIndex] = useState(0); const [mentionRange, setMentionRange] = useState<{ end: number; start: number; trigger: string } | null>(null); const [questionDraft, setQuestionDraft] = useState(""); const [questionOther, setQuestionOther] = useState(false); const [files, setFiles] = useState([]); const [internalError, setInternalError] = useState(); const [taskProgressExpanded, setTaskProgressExpanded] = useState(true); const prompt = controlledValue ?? internalValue; const activeCapability = capabilities.find((item) => item.value === selectedCapability) ?? capabilities[0]; const activeModel = models.find((item) => item.value === selectedModel) ?? models[0]; const questionType = question?.type ?? (question?.options?.length ? "single-choice" : "text"); const taskProgressTasks = tasks.filter(isTaskProgressItem); useEffect(() => { const handlePointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node) || popoverRootRef.current?.contains(event.target)) return; setCapabilityOpen(false); setModelOpen(false); setTaskOpen(false); setMentionOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, []); useEffect(() => { setQuestionDraft(questionType === "multi-choice" ? [] : ""); setQuestionOther(false); }, [question?.id, questionType]); const questionOptions = question ? [...(question.options ?? []), ...(question.allowCustomAnswer && (questionType === "single-choice" || questionType === "multi-choice") ? [{ id: "__other__", label: question.otherLabel ?? "Other" }] : [])] : []; const activeTask = tasks.find((item) => item.id === selectedTask) ?? tasks[0]; const resolvedAccept = acceptedFileTypes?.join(","); const resolvedError = error ?? internalError; const resolvedStreamError = streamError ?? voiceError; const visibleError = resolvedError === dismissedError ? undefined : resolvedError; const visibleStreamError = resolvedStreamError === dismissedStreamError ? undefined : resolvedStreamError; const hasContent = Boolean(prompt.trim() || files.length); const canComposeWhileLoading = Boolean(loading && (queueEnabled || onQueueSubmit || onForceSteer)); const resolvedMinRows = clampRows(minRows, 2, 1); const resolvedMaxRows = Math.max(resolvedMinRows, clampRows(maxRows, 6, resolvedMinRows)); useEffect(() => { setDismissedError(undefined); }, [resolvedError]); useEffect(() => { setDismissedStreamError(undefined); }, [resolvedStreamError]); useEffect(() => { filesRef.current = files; }, [files]); useEffect(() => { return () => { filesRef.current.forEach((file) => { if (file.url) URL.revokeObjectURL(file.url); }); }; }, []); useEffect(() => () => recognitionRef.current?.abort(), []); useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "auto"; const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 24; const minHeight = lineHeight * resolvedMinRows; const maxHeight = lineHeight * resolvedMaxRows; textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight)}px`; textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden"; }, [prompt, resolvedMaxRows, resolvedMinRows]); function updateMentionSuggestions(nextValue: string, caret: number) { const beforeCaret = nextValue.slice(0, caret); const match = beforeCaret.match(/(?:^|\s)([@#/])([\w-]*)$/); if (!match) { setMentionOpen(false); setMentionMatches([]); setMentionRange(null); return; } const trigger = match[1]; const query = match[2].toLowerCase(); const tokenStart = caret - match[0].length + (match[0].startsWith(" ") ? 1 : 0); const matches = (onMentionSearch ? onMentionSearch(query, trigger) : mentions.filter((mention) => { const triggerMatches = !mention.trigger || mention.trigger === trigger; return triggerMatches && `${mention.label} ${mention.kind ?? ""}`.toLowerCase().includes(query); })).slice(0, 8); setMentionMatches(matches); setMentionRange({ end: caret, start: tokenStart, trigger }); setMentionIndex(0); setMentionOpen(matches.length > 0); } function updatePrompt(nextValue: string) { if (controlledValue === undefined) setInternalValue(nextValue); onChange?.(nextValue); if (internalError) setInternalError(undefined); } function startVoiceInput() { if (disabled || (loading && !canComposeWhileLoading)) return; if (voiceActive) { recognitionRef.current?.abort(); setVoiceActive(false); return; } if (!voiceToText) { onVoiceClick?.(); return; } const speechWindow = window as typeof window & { SpeechRecognition?: new () => SpeechRecognitionLike; webkitSpeechRecognition?: new () => SpeechRecognitionLike }; const Recognition = speechWindow.SpeechRecognition ?? speechWindow.webkitSpeechRecognition; if (!Recognition) { setVoiceError("Voice input is not supported in this browser."); return; } const recognition = new Recognition(); recognitionRef.current = recognition; recognition.onresult = (event) => { const transcript = Array.from(event.results).map((result) => result[0]?.transcript ?? "").join(" ").trim(); if (transcript) { updatePrompt(`${prompt}${prompt.trim() ? " " : ""}${transcript}`); onVoiceTranscript?.(transcript); onNarrationCompleted?.(transcript); } }; recognition.onerror = (event) => { setVoiceError(event.error ? `Voice input failed: ${event.error}.` : "Voice input failed. Try again."); setVoiceActive(false); }; recognition.onend = () => setVoiceActive(false); setVoiceError(undefined); setVoiceActive(true); recognition.start(); } function selectMention(mention: AIInputMention) { if (!mentionRange) return; const before = prompt.slice(0, mentionRange.start); const after = prompt.slice(mentionRange.end); const nextValue = `${before}${mentionRange.trigger}${mention.value ?? mention.label} ${after}`; updatePrompt(nextValue); setMentionOpen(false); setMentionMatches([]); onMentionSelect?.(mention); requestAnimationFrame(() => { const caret = before.length + mentionRange.trigger.length + (mention.value ?? mention.label).length + 1; textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(caret, caret); }); } function selectTask(task: AIInputTask) { setSelectedTask(task.id); onTaskChange?.(task.id); setTaskOpen(false); } function handleFiles(fileList: FileList | null) { if (!fileList || fileList.length === 0 || disabled || (loading && !canComposeWhileLoading)) return; const nextFiles = Array.from(fileList); const normalizedFiles = multiple ? nextFiles : nextFiles.slice(0, 1); if (normalizedFiles.length > maxFiles) { setInternalError(`You can attach at most ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`); return; } const invalidFile = normalizedFiles.find((file) => !matchesAcceptedFileTypes(file, acceptedFileTypes)); if (invalidFile) { setInternalError(`"${invalidFile.name}" is not an allowed file type.`); return; } setFiles((current) => { const existing = multiple ? current : []; const incoming = normalizedFiles.map((file) => ({ file, name: file.name, size: file.size, type: file.type, url: file.type.startsWith("image/") || file.type === "application/pdf" ? URL.createObjectURL(file) : undefined, })); if (existing.length + incoming.length > maxFiles) { incoming.forEach((file) => { if (file.url) URL.revokeObjectURL(file.url); }); setInternalError(`You can attach at most ${maxFiles} file${maxFiles === 1 ? "" : "s"} total.`); return current; } if (!multiple) { current.forEach((file) => { if (file.url) URL.revokeObjectURL(file.url); }); } return [...existing, ...incoming]; }); if (multiple) setInternalError(undefined); } function removeFile(index: number) { setFiles((current) => { const removed = current[index]; if (removed?.url) URL.revokeObjectURL(removed.url); return current.filter((_, itemIndex) => itemIndex !== index); }); } function clearComposer() { updatePrompt(""); setFiles((current) => { current.forEach((file) => { if (file.url) URL.revokeObjectURL(file.url); }); return []; }); setInternalError(undefined); } function answerQuestion(value: string | string[], selectedOptions?: AIInputQuestionOption[]) { if (!question || !onQuestionAnswer) return; onQuestionAnswer({ questionId: question.id, selectedOptions, type: questionType, value }, question); } function selectQuestionOption(option: AIInputQuestionOption) { if (option.id === "__other__") { setQuestionOther(true); setQuestionDraft(questionType === "multi-choice" ? [] : ""); return; } if (questionType === "multi-choice") { const current = Array.isArray(questionDraft) ? questionDraft : []; setQuestionDraft(current.includes(option.id) ? current.filter((id) => id !== option.id) : [...current, option.id]); return; } setQuestionDraft(option.id); } function submitQuestionDraft() { const value = questionDraft; if (Array.isArray(value) && value.length === 0 && !questionOther) return; if (typeof value === "string" && !value.trim()) return; const selectedOptions = questionType === "multi-choice" && Array.isArray(value) ? question?.options?.filter((option) => value.includes(option.id)) : questionType === "single-choice" && typeof value === "string" ? question?.options?.filter((option) => option.id === value) : undefined; answerQuestion(value, selectedOptions); } function handleSubmit() { if (disabled) return; const normalizedPrompt = prompt.trim(); if (!normalizedPrompt && files.length === 0) { setInternalError("Add a prompt or attach at least one file."); return; } setInternalError(undefined); const payload = { capability: selectedCapability || undefined, files: files.map((file) => file.file).filter(Boolean) as File[], model: selectedModel || undefined, prompt: normalizedPrompt, task: selectedTask || undefined, }; if (loading) { onQueueSubmit?.(payload); if (onQueueSubmit) clearComposer(); return; } onSubmit?.(payload); clearComposer(); } function forceSteer() { if (!onForceSteer || !hasContent) return; onForceSteer({ capability: selectedCapability || undefined, files: files.map((file) => file.file).filter(Boolean) as File[], model: selectedModel || undefined, prompt: prompt.trim(), task: selectedTask || undefined, }); clearComposer(); } function handleKeyDown(event: KeyboardEvent) { if (mentionOpen && mentionMatches.length > 0) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setMentionIndex((current) => (event.key === "ArrowDown" ? (current + 1) % mentionMatches.length : (current - 1 + mentionMatches.length) % mentionMatches.length)); return; } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); selectMention(mentionMatches[mentionIndex]); return; } if (event.key === "Escape") { event.preventDefault(); setMentionOpen(false); return; } } if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; event.preventDefault(); handleSubmit(); } function selectCapability(capability: AIInputCapability) { setSelectedCapability(capability.value); onCapabilityChange?.(capability.value); setCapabilityOpen(false); } function selectModel(nextModel: AIInputModel) { setSelectedModel(nextModel.value); onModelChange?.(nextModel.value); setModelOpen(false); } const compactInput = (