Files
claude-code/packages/remote-control-server/web/components/ai-elements/prompt-input.tsx
claude-code-best 34154ee3f5 feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)
* fix: 修复超时问题

* feat: 添加 acp-link 代码

* refactor: 样式重构完成

* feat: RCS 添加 ACP 后端支持

- 新增 ACP WebSocket handler (agent 注册、EventBus 订阅)
- 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发)
- 新增 SSE event stream 供外部消费者订阅 channel group 事件
- ACP REST 接口无鉴权 (agents、channel-groups)
- WebSocket 端点保留 token 鉴权
- SPA 路由 /acp/ 指向 acp.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加 ACP 专属前端界面

- 新增 /acp/ SPA 页面 (agent 列表 + 实时交互)
- Agent 列表按 channel group 分组,显示在线状态
- 通过 RCS WebSocket relay 与 agent 通信
- Vite multi-page 构建 (index.html + acp.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 支持 RCS relay 双向通信

- rcs-upstream 新增 messageHandler 转发非控制消息
- server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息
- newSession/loadSession 补充 mcpServers 参数
- 连接成功后显示 ACP Dashboard URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 移除 FileExplorer 及文件操作相关代码

- 删除 FileExplorer 组件
- ACPMain 移除 Files tab,仅保留 Chat 和 History
- client.ts 移除 listDir/readFile/onFileChanges 等方法
- types.ts 移除 FileItem/FileContent/FileChange 等类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复类型问题

* feat: RCS 后端统一 ACP/Bridge 注册逻辑

- store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑
- store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim
- environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id
- session: resolveOwnedWebSessionId 支持无 owner session 自动绑定
- acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册
- acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证
- event-bus: 增加 error 类型 payload 日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 改 REST 注册 + WS identify 两步流程

- rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册
- rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId
- rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定
- server: 修复心跳跳过 relay 虚拟连接的 bug
- server: maxSessions 配置传入 RCS upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 前端统一 Chat 组件 + ACP 聊天界面重构

- 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu
- ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission)
- rcs-chat-adapter: 统一 bridge session SSE 适配器
- ACPClient: 增强 session 管理、permission 流程、streaming 支持
- index.css: 新增 chat 相关样式、动画、布局
- useCommands: 新增快捷命令 hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId

- 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录
- SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件
- App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID
- Dashboard: 统一 session 列表导航,ACP 显示紫色标签
- relay-client: 改用 UUID 认证替代 API token
- EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code)
- index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* build: 更新构建及测试修复

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 17:59:29 +08:00

1463 lines
39 KiB
TypeScript

"use client";
import { Button } from "../ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "../ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { cn } from "../../src/lib/utils";
import type { ChatStatus, FileUIPart } from "ai";
import {
CornerDownLeftIcon,
ImageIcon,
Loader2Icon,
MicIcon,
PaperclipIcon,
PlusIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import { nanoid } from "nanoid";
import {
type ChangeEvent,
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
Fragment,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
type RefObject,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
// ============================================================================
// Provider Context & Types
// ============================================================================
// Extended file type that includes the original File object for reliable reading
// in Chrome extensions where fetch(blobUrl) may fail
export type FileUIPartWithFile = FileUIPart & { id: string; _file?: File };
export type AttachmentsContext = {
files: FileUIPartWithFile[];
add: (files: File[] | FileList) => void;
remove: (id: string) => void;
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
};
export type TextInputContext = {
value: string;
setInput: (v: string) => void;
clear: () => void;
};
export type PromptInputControllerProps = {
textInput: TextInputContext;
attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
open: () => void
) => void;
};
const PromptInputController = createContext<PromptInputControllerProps | null>(
null
);
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
);
}
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
);
}
return ctx;
};
const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
children,
}: PromptInputProviderProps) {
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<FileUIPartWithFile[]>(
[]
);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files);
if (incoming.length === 0) {
return;
}
setAttachmentFiles((prev) =>
prev.concat(
incoming.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
_file: file, // Store original File for reliable reading in Chrome extensions
}))
)
);
}, []);
const remove = useCallback((id: string) => {
setAttachmentFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
return [];
});
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
};
}, []);
const openFileDialog = useCallback(() => {
openRef.current?.();
}, []);
const attachments = useMemo<AttachmentsContext>(
() => ({
files: attachmentFiles,
add,
remove,
clear,
openFileDialog,
fileInputRef,
}),
[attachmentFiles, add, remove, clear, openFileDialog]
);
const __registerFileInput = useCallback(
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
fileInputRef.current = ref.current;
openRef.current = open;
},
[]
);
const controller = useMemo<PromptInputControllerProps>(
() => ({
textInput: {
value: textInput,
setInput: setTextInput,
clear: clearInput,
},
attachments,
__registerFileInput,
}),
[textInput, clearInput, attachments, __registerFileInput]
);
return (
<PromptInputController.Provider value={controller}>
<ProviderAttachmentsContext.Provider value={attachments}>
{children}
</ProviderAttachmentsContext.Provider>
</PromptInputController.Provider>
);
}
// ============================================================================
// Component Context & Hooks
// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
// Dual-mode: prefer provider if present, otherwise use local
const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext);
const context = provider ?? local;
if (!context) {
throw new Error(
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
);
}
return context;
};
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPartWithFile;
className?: string;
};
export function PromptInputAttachment({
data,
className,
...props
}: PromptInputAttachmentProps) {
const attachments = usePromptInputAttachments();
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<PromptInputHoverCard>
<HoverCardTrigger asChild>
<div
className={cn(
"group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
className
)}
key={data.id}
{...props}
>
<div className="relative size-5 shrink-0">
<div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
{isImage ? (
<img
alt={filename || "attachment"}
className="size-5 object-cover"
height={20}
src={data.url}
width={20}
/>
) : (
<div className="flex size-5 items-center justify-center text-muted-foreground">
<PaperclipIcon className="size-3" />
</div>
)}
</div>
<Button
aria-label="Remove attachment"
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
onClick={(e) => {
e.stopPropagation();
attachments.remove(data.id);
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
</div>
<span className="flex-1 truncate">{attachmentLabel}</span>
</div>
</HoverCardTrigger>
<PromptInputHoverCardContent className="w-auto p-2">
<div className="w-auto space-y-3">
{isImage && (
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
<img
alt={filename || "attachment preview"}
className="max-h-full max-w-full object-contain"
height={384}
src={data.url}
width={448}
/>
</div>
)}
<div className="flex items-center gap-2.5">
<div className="min-w-0 flex-1 space-y-1 px-0.5">
<h4 className="truncate font-semibold text-sm leading-none">
{filename || (isImage ? "Image" : "Attachment")}
</h4>
{data.mediaType && (
<p className="truncate font-mono text-muted-foreground text-xs">
{data.mediaType}
</p>
)}
</div>
</div>
</div>
</PromptInputHoverCardContent>
</PromptInputHoverCard>
);
}
export type PromptInputAttachmentsProps = Omit<
HTMLAttributes<HTMLDivElement>,
"children"
> & {
children: (attachment: FileUIPartWithFile) => ReactNode;
};
export function PromptInputAttachments({
children,
className,
...props
}: PromptInputAttachmentsProps) {
const attachments = usePromptInputAttachments();
if (!attachments.files.length) {
return null;
}
return (
<div
className={cn("flex flex-wrap items-center gap-2 p-3 w-full", className)}
{...props}
>
{attachments.files.map((file) => (
<Fragment key={file.id}>{children(file)}</Fragment>
))}
</div>
);
}
export type PromptInputActionAddAttachmentsProps = ComponentProps<
typeof DropdownMenuItem
> & {
label?: string;
};
export const PromptInputActionAddAttachments = ({
label = "Add photos or files",
...props
}: PromptInputActionAddAttachmentsProps) => {
const attachments = usePromptInputAttachments();
return (
<DropdownMenuItem
{...props}
onSelect={(e) => {
e.preventDefault();
attachments.openFileDialog();
}}
>
<ImageIcon className="mr-2 size-4" /> {label}
</DropdownMenuItem>
);
};
export type PromptInputMessage = {
text: string;
files: FileUIPart[];
};
export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
"onSubmit" | "onError"
> & {
accept?: string; // e.g., "image/*" or leave undefined for any
multiple?: boolean;
// When true, accepts drops anywhere on document. Default false (opt-in).
globalDrop?: boolean;
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
maxFileSize?: number; // bytes
onError?: (err: {
code: "max_files" | "max_file_size" | "accept";
message: string;
}) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
) => void | Promise<void>;
};
export const PromptInput = ({
className,
accept,
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
// Refs
const inputRef = useRef<HTMLInputElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<FileUIPartWithFile[]>([]);
const files = usingProvider ? controller.attachments.files : items;
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files);
filesRef.current = files;
const openFileDialogLocal = useCallback(() => {
inputRef.current?.click();
}, []);
const matchesAccept = useCallback(
(f: File) => {
if (!accept || accept.trim() === "") {
return true;
}
if (accept.includes("image/*")) {
return f.type.startsWith("image/");
}
// NOTE: keep simple; expand as needed
return true;
},
[accept]
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
code: "accept",
message: "No files match the accepted types.",
});
return;
}
const withinSize = (f: File) =>
maxFileSize ? f.size <= maxFileSize : true;
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: "max_file_size",
message: "All files exceed the maximum size.",
});
return;
}
setItems((prev) => {
const capacity =
typeof maxFiles === "number"
? Math.max(0, maxFiles - prev.length)
: undefined;
const capped =
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
const next: FileUIPartWithFile[] = [];
for (const file of capped) {
next.push({
id: nanoid(),
type: "file",
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
_file: file, // Store original File for reliable reading in Chrome extensions
});
}
return prev.concat(next);
});
},
[matchesAccept, maxFiles, maxFileSize, onError]
);
const removeLocal = useCallback(
(id: string) =>
setItems((prev) => {
const found = prev.find((file) => file.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((file) => file.id !== id);
}),
[]
);
const clearLocal = useCallback(
() =>
setItems((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
return [];
}),
[]
);
const add = usingProvider ? controller.attachments.add : addLocal;
const remove = usingProvider ? controller.attachments.remove : removeLocal;
const clear = usingProvider ? controller.attachments.clear : clearLocal;
const openFileDialog = usingProvider
? controller.attachments.openFileDialog
: openFileDialogLocal;
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => {
if (!usingProvider) return;
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
}, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons
// The syncHiddenInput prop is no longer functional
useEffect(() => {
if (syncHiddenInput && inputRef.current && files.length === 0) {
inputRef.current.value = "";
}
}, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in)
useEffect(() => {
const form = formRef.current;
if (!form) return;
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
form.addEventListener("dragover", onDragOver);
form.addEventListener("drop", onDrop);
return () => {
form.removeEventListener("dragover", onDragOver);
form.removeEventListener("drop", onDrop);
};
}, [add]);
useEffect(() => {
if (!globalDrop) return;
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
}, [add, globalDrop]);
useEffect(
() => () => {
if (!usingProvider) {
for (const f of filesRef.current) {
if (f.url) URL.revokeObjectURL(f.url);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
[usingProvider]
);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
if (event.currentTarget.files) {
add(event.currentTarget.files);
}
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = "";
};
// Convert file to data URL, preferring direct File reading over blob URL fetch
// This is critical for Chrome extensions where fetch(blobUrl) may fail
const convertToDataUrl = async (
url: string,
file?: File
): Promise<string | null> => {
// If we have the original File object, use FileReader directly
// This is more reliable than fetch(blobUrl) in Chrome extensions
if (file) {
console.log("[PromptInput] Reading file directly with FileReader...");
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
console.log(
"[PromptInput] FileReader complete, result length:",
(reader.result as string)?.length
);
resolve(reader.result as string);
};
reader.onerror = () => {
console.error("[PromptInput] FileReader error:", reader.error);
resolve(null);
};
reader.readAsDataURL(file);
});
}
// Fallback: try to fetch the blob URL (works in regular web pages)
try {
console.log("[PromptInput] Converting blob URL to data URL via fetch...");
const response = await fetch(url);
if (!response.ok) {
console.error(
"[PromptInput] Fetch failed:",
response.status,
response.statusText
);
return null;
}
const blob = await response.blob();
console.log(
"[PromptInput] Blob fetched, size:",
blob.size,
"type:",
blob.type
);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
console.log(
"[PromptInput] FileReader complete, result length:",
(reader.result as string)?.length
);
resolve(reader.result as string);
};
reader.onerror = () => {
console.error("[PromptInput] FileReader error:", reader.error);
resolve(null);
};
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("[PromptInput] convertToDataUrl error:", error);
return null;
}
};
const ctx = useMemo<AttachmentsContext>(
() => ({
files: files.map((item) => ({ ...item, id: item.id })),
add,
remove,
clear,
openFileDialog,
fileInputRef: inputRef,
}),
[files, add, remove, clear, openFileDialog]
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const form = event.currentTarget;
const text = usingProvider
? controller.textInput.value
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
})();
// Reset form immediately after capturing text to avoid race condition
// where user input during async blob conversion would be lost
if (!usingProvider) {
form.reset();
}
// Convert blob URLs to data URLs asynchronously
// Pass the original File object for reliable reading in Chrome extensions
Promise.all(
files.map(async ({ id, _file, ...item }) => {
if (item.url && item.url.startsWith("blob:")) {
const dataUrl = await convertToDataUrl(item.url, _file);
// If conversion failed, keep the original blob URL
return {
...item,
url: dataUrl ?? item.url,
};
}
return item;
})
)
.then((convertedFiles: FileUIPart[]) => {
try {
const result = onSubmit({ text, files: convertedFiles }, event);
// Handle both sync and async onSubmit
if (result instanceof Promise) {
result
.then(() => {
clear();
if (usingProvider) {
controller.textInput.clear();
}
})
.catch(() => {
// Don't clear on error - user may want to retry
});
} else {
// Sync function completed without throwing, clear attachments
clear();
if (usingProvider) {
controller.textInput.clear();
}
}
} catch {
// Don't clear on error - user may want to retry
}
})
.catch(() => {
// Don't clear on error - user may want to retry
});
};
// Render with or without local provider
const inner = (
<>
<input
accept={accept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
onChange={handleChange}
ref={inputRef}
title="Upload files"
type="file"
/>
<form
className={cn("w-full", className)}
onSubmit={handleSubmit}
ref={formRef}
{...props}
>
<InputGroup className="overflow-hidden">{children}</InputGroup>
</form>
</>
);
return usingProvider ? (
inner
) : (
<LocalAttachmentsContext.Provider value={ctx}>
{inner}
</LocalAttachmentsContext.Provider>
);
};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div className={cn("contents", className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isComposing || e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
return;
}
e.preventDefault();
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
const submitButton = form?.querySelector(
'button[type="submit"]'
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
}
form?.requestSubmit();
}
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
attachments.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
}
}
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.kind === "file") {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
event.preventDefault();
attachments.add(files);
}
};
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
);
};
export type PromptInputHeaderProps = Omit<
ComponentProps<typeof InputGroupAddon>,
"align"
>;
export const PromptInputHeader = ({
className,
...props
}: PromptInputHeaderProps) => (
<InputGroupAddon
align="block-end"
className={cn("order-first flex-wrap gap-1", className)}
{...props}
/>
);
export type PromptInputFooterProps = Omit<
ComponentProps<typeof InputGroupAddon>,
"align"
>;
export const PromptInputFooter = ({
className,
...props
}: PromptInputFooterProps) => (
<InputGroupAddon
align="block-end"
className={cn("justify-between gap-1", className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
export const PromptInputButton = ({
variant = "ghost",
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
return (
<InputGroupButton
className={cn(className)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu {...props} />
);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuContentProps = ComponentProps<
typeof DropdownMenuContent
>;
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
<DropdownMenuContent align="start" className={cn(className)} {...props} />
);
export type PromptInputActionMenuItemProps = ComponentProps<
typeof DropdownMenuItem
>;
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => (
<DropdownMenuItem className={cn(className)} {...props} />
);
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon-sm",
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <CornerDownLeftIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<InputGroupButton
aria-label="Submit"
className={cn(className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</InputGroupButton>
);
};
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
onend: ((this: SpeechRecognition, ev: Event) => any) | null;
onresult:
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
| null;
onerror:
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
| null;
}
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
type SpeechRecognitionResultList = {
readonly length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
};
type SpeechRecognitionResult = {
readonly length: number;
item(index: number): SpeechRecognitionAlternative;
[index: number]: SpeechRecognitionAlternative;
isFinal: boolean;
};
type SpeechRecognitionAlternative = {
transcript: string;
confidence: number;
};
interface SpeechRecognitionErrorEvent extends Event {
error: string;
}
declare global {
interface Window {
SpeechRecognition: {
new (): SpeechRecognition;
};
webkitSpeechRecognition: {
new (): SpeechRecognition;
};
}
}
export type PromptInputSpeechButtonProps = ComponentProps<
typeof PromptInputButton
> & {
textareaRef?: RefObject<HTMLTextAreaElement | null>;
onTranscriptionChange?: (text: string) => void;
};
export const PromptInputSpeechButton = ({
className,
textareaRef,
onTranscriptionChange,
...props
}: PromptInputSpeechButtonProps) => {
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
null
);
const recognitionRef = useRef<SpeechRecognition | null>(null);
useEffect(() => {
if (
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
) {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
const speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true;
speechRecognition.interimResults = true;
speechRecognition.lang = "en-US";
speechRecognition.onstart = () => {
setIsListening(true);
};
speechRecognition.onend = () => {
setIsListening(false);
};
speechRecognition.onresult = (event) => {
let finalTranscript = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result?.isFinal) {
finalTranscript += result[0]?.transcript ?? "";
}
}
if (finalTranscript && textareaRef?.current) {
const textarea = textareaRef.current;
const currentValue = textarea.value;
const newValue =
currentValue + (currentValue ? " " : "") + finalTranscript;
textarea.value = newValue;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
onTranscriptionChange?.(newValue);
}
};
speechRecognition.onerror = (event) => {
console.error("Speech recognition error:", event.error);
setIsListening(false);
};
recognitionRef.current = speechRecognition;
setRecognition(speechRecognition);
}
return () => {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
};
}, [textareaRef, onTranscriptionChange]);
const toggleListening = useCallback(() => {
if (!recognition) {
return;
}
if (isListening) {
recognition.stop();
} else {
recognition.start();
}
}, [recognition, isListening]);
return (
<PromptInputButton
className={cn(
"relative transition-all duration-200",
isListening && "animate-pulse bg-accent text-accent-foreground",
className
)}
disabled={!recognition}
onClick={toggleListening}
{...props}
>
<MicIcon className="size-4" />
</PromptInputButton>
);
};
export type PromptInputSelectProps = ComponentProps<typeof Select>;
export const PromptInputSelect = (props: PromptInputSelectProps) => (
<Select {...props} />
);
export type PromptInputSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputSelectTrigger = ({
className,
...props
}: PromptInputSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className
)}
{...props}
/>
);
export type PromptInputSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputSelectContent = ({
className,
...props
}: PromptInputSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputSelectItem = ({
className,
...props
}: PromptInputSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
export const PromptInputSelectValue = ({
className,
...props
}: PromptInputSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const PromptInputHoverCardTrigger = (
props: PromptInputHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type PromptInputHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const PromptInputHoverCardContent = ({
align = "start",
...props
}: PromptInputHoverCardContentProps) => (
<HoverCardContent align={align} {...props} />
);
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabsList = ({
className,
...props
}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTab = ({
className,
...props
}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
export const PromptInputTabLabel = ({
className,
...props
}: PromptInputTabLabelProps) => (
<h3
className={cn(
"mb-2 px-3 font-medium text-muted-foreground text-xs",
className
)}
{...props}
/>
);
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabBody = ({
className,
...props
}: PromptInputTabBodyProps) => (
<div className={cn("space-y-1", className)} {...props} />
);
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabItem = ({
className,
...props
}: PromptInputTabItemProps) => (
<div
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
className
)}
{...props}
/>
);
export type PromptInputCommandProps = ComponentProps<typeof Command>;
export const PromptInputCommand = ({
className,
...props
}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
export const PromptInputCommandInput = ({
className,
...props
}: PromptInputCommandInputProps) => (
<CommandInput className={cn(className)} {...props} />
);
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
export const PromptInputCommandList = ({
className,
...props
}: PromptInputCommandListProps) => (
<CommandList className={cn(className)} {...props} />
);
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
export const PromptInputCommandEmpty = ({
className,
...props
}: PromptInputCommandEmptyProps) => (
<CommandEmpty className={cn(className)} {...props} />
);
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
export const PromptInputCommandGroup = ({
className,
...props
}: PromptInputCommandGroupProps) => (
<CommandGroup className={cn(className)} {...props} />
);
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
export const PromptInputCommandItem = ({
className,
...props
}: PromptInputCommandItemProps) => (
<CommandItem className={cn(className)} {...props} />
);
export type PromptInputCommandSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const PromptInputCommandSeparator = ({
className,
...props
}: PromptInputCommandSeparatorProps) => (
<CommandSeparator className={cn(className)} {...props} />
);