feat: implement basic web app

This commit is contained in:
Henry Li
2026-01-15 23:40:21 +08:00
parent b44144dd2c
commit 9f2b94ed52
49 changed files with 4142 additions and 626 deletions

View File

@@ -37,7 +37,7 @@ import {
import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart } from "ai";
import {
CornerDownLeftIcon,
ArrowUpIcon,
ImageIcon,
Loader2Icon,
MicIcon,
@@ -95,22 +95,22 @@ export type PromptInputControllerProps = {
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
open: () => void
open: () => void,
) => void;
};
const PromptInputController = createContext<PromptInputControllerProps | null>(
null
null,
);
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
null,
);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
);
}
return ctx;
@@ -124,7 +124,7 @@ export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
);
}
return ctx;
@@ -170,8 +170,8 @@ export function PromptInputProvider({
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
}))
)
})),
),
);
}, []);
@@ -224,7 +224,7 @@ export function PromptInputProvider({
openFileDialog,
fileInputRef,
}),
[attachmentFiles, add, remove, clear, openFileDialog]
[attachmentFiles, add, remove, clear, openFileDialog],
);
const __registerFileInput = useCallback(
@@ -232,7 +232,7 @@ export function PromptInputProvider({
fileInputRef.current = ref.current;
openRef.current = open;
},
[]
[],
);
const controller = useMemo<PromptInputControllerProps>(
@@ -245,7 +245,7 @@ export function PromptInputProvider({
attachments,
__registerFileInput,
}),
[textInput, clearInput, attachments, __registerFileInput]
[textInput, clearInput, attachments, __registerFileInput],
);
return (
@@ -270,7 +270,7 @@ export const usePromptInputAttachments = () => {
const context = provider ?? local;
if (!context) {
throw new Error(
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
);
}
return context;
@@ -301,14 +301,14 @@ export function PromptInputAttachment({
<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
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
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">
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
{isImage ? (
<img
alt={filename || "attachment"}
@@ -318,7 +318,7 @@ export function PromptInputAttachment({
width={20}
/>
) : (
<div className="flex size-5 items-center justify-center text-muted-foreground">
<div className="text-muted-foreground flex size-5 items-center justify-center">
<PaperclipIcon className="size-3" />
</div>
)}
@@ -356,11 +356,11 @@ export function PromptInputAttachment({
)}
<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">
<h4 className="truncate text-sm leading-none font-semibold">
{filename || (isImage ? "Image" : "Attachment")}
</h4>
{data.mediaType && (
<p className="truncate font-mono text-muted-foreground text-xs">
<p className="text-muted-foreground truncate font-mono text-xs">
{data.mediaType}
</p>
)}
@@ -392,7 +392,7 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex flex-wrap items-center gap-2 p-3 w-full", className)}
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
@@ -451,7 +451,7 @@ export type PromptInputProps = Omit<
}) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
event: FormEvent<HTMLFormElement>,
) => void | Promise<void>;
};
@@ -507,7 +507,7 @@ export const PromptInput = ({
return f.type === pattern;
});
},
[accept]
[accept],
);
const addLocal = useCallback(
@@ -558,7 +558,7 @@ export const PromptInput = ({
return prev.concat(next);
});
},
[matchesAccept, maxFiles, maxFileSize, onError]
[matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
@@ -570,7 +570,7 @@ export const PromptInput = ({
}
return prev.filter((file) => file.id !== id);
}),
[]
[],
);
const clearLocal = useCallback(
@@ -583,7 +583,7 @@ export const PromptInput = ({
}
return [];
}),
[]
[],
);
const add = usingProvider ? controller.attachments.add : addLocal;
@@ -611,7 +611,7 @@ export const PromptInput = ({
useEffect(() => {
const form = formRef.current;
if (!form) return;
if (globalDrop) return // when global drop is on, let the document-level handler own drops
if (globalDrop) return; // when global drop is on, let the document-level handler own drops
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
@@ -667,7 +667,7 @@ export const PromptInput = ({
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
[usingProvider]
[usingProvider],
);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
@@ -679,7 +679,7 @@ export const PromptInput = ({
};
const convertBlobUrlToDataUrl = async (
url: string
url: string,
): Promise<string | null> => {
try {
const response = await fetch(url);
@@ -704,7 +704,7 @@ export const PromptInput = ({
openFileDialog,
fileInputRef: inputRef,
}),
[files, add, remove, clear, openFileDialog]
[files, add, remove, clear, openFileDialog],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
@@ -736,7 +736,7 @@ export const PromptInput = ({
};
}
return item;
})
}),
)
.then((convertedFiles: FileUIPart[]) => {
try {
@@ -839,7 +839,7 @@ export const PromptInputTextarea = ({
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
const submitButton = form?.querySelector(
'button[type="submit"]'
'button[type="submit"]',
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
@@ -1030,7 +1030,7 @@ export const PromptInputSubmit = ({
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <CornerDownLeftIcon className="size-4" />;
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
@@ -1123,7 +1123,7 @@ export const PromptInputSpeechButton = ({
}: PromptInputSpeechButtonProps) => {
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
null
null,
);
const recognitionRef = useRef<SpeechRecognition | null>(null);
@@ -1202,8 +1202,8 @@ export const PromptInputSpeechButton = ({
<PromptInputButton
className={cn(
"relative transition-all duration-200",
isListening && "animate-pulse bg-accent text-accent-foreground",
className
isListening && "bg-accent text-accent-foreground animate-pulse",
className,
)}
disabled={!recognition}
onClick={toggleListening}
@@ -1230,9 +1230,9 @@ export const PromptInputSelectTrigger = ({
}: PromptInputSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className
className,
)}
{...props}
/>
@@ -1282,7 +1282,7 @@ export type PromptInputHoverCardTriggerProps = ComponentProps<
>;
export const PromptInputHoverCardTrigger = (
props: PromptInputHoverCardTriggerProps
props: PromptInputHoverCardTriggerProps,
) => <HoverCardTrigger {...props} />;
export type PromptInputHoverCardContentProps = ComponentProps<
@@ -1318,8 +1318,8 @@ export const PromptInputTabLabel = ({
}: PromptInputTabLabelProps) => (
<h3
className={cn(
"mb-2 px-3 font-medium text-muted-foreground text-xs",
className
"text-muted-foreground mb-2 px-3 text-xs font-medium",
className,
)}
{...props}
/>
@@ -1342,8 +1342,8 @@ export const PromptInputTabItem = ({
}: PromptInputTabItemProps) => (
<div
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
className
"hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs",
className,
)}
{...props}
/>