feat: implement basic web app

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

View File

@@ -23,14 +23,14 @@ type ChainOfThoughtContextValue = {
};
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null
null,
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought"
"ChainOfThought components must be used within ChainOfThought",
);
}
return context;
@@ -59,7 +59,7 @@ export const ChainOfThought = memo(
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
[isOpen, setIsOpen],
);
return (
@@ -72,40 +72,42 @@ export const ChainOfThought = memo(
</div>
</ChainOfThoughtContext.Provider>
);
}
},
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
> & {
icon?: React.ReactElement;
};
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
{icon ?? <BrainIcon className="size-4" />}
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
},
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
@@ -137,13 +139,13 @@ export const ChainOfThoughtStep = memo(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
@@ -154,7 +156,7 @@ export const ChainOfThoughtStep = memo(
</div>
</div>
);
}
},
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
@@ -165,7 +167,7 @@ export const ChainOfThoughtSearchResults = memo(
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
)
),
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
@@ -173,13 +175,13 @@ export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
),
);
export type ChainOfThoughtContentProps = ComponentProps<
@@ -195,8 +197,8 @@ export const ChainOfThoughtContent = memo(
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className,
)}
{...props}
>
@@ -204,7 +206,7 @@ export const ChainOfThoughtContent = memo(
</CollapsibleContent>
</Collapsible>
);
}
},
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
@@ -214,12 +216,12 @@ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
<div className="bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
),
);
ChainOfThought.displayName = "ChainOfThought";

View File

@@ -1,10 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
@@ -30,9 +27,9 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
"group flex w-full flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
className,
)}
{...props}
/>
@@ -47,10 +44,10 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden",
"group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-assistant]:text-foreground",
className
className,
)}
{...props}
>
@@ -116,7 +113,7 @@ type MessageBranchContextType = {
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
null,
);
const useMessageBranch = () => {
@@ -124,7 +121,7 @@ const useMessageBranch = () => {
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
"MessageBranch components must be used within MessageBranch",
);
}
@@ -201,7 +198,7 @@ export const MessageBranchContent = ({
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
@@ -294,8 +291,8 @@ export const MessageBranchPage = ({
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
"text-muted-foreground border-none bg-transparent shadow-none",
className,
)}
{...props}
>
@@ -311,12 +308,12 @@ export const MessageResponse = memo(
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
@@ -343,7 +340,7 @@ export function MessageAttachment({
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className
className,
)}
{...props}
>
@@ -359,7 +356,7 @@ export function MessageAttachment({
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
@@ -376,7 +373,7 @@ export function MessageAttachment({
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
@@ -387,7 +384,7 @@ export function MessageAttachment({
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
@@ -420,7 +417,7 @@ export function MessageAttachments({
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className
className,
)}
{...props}
>
@@ -439,7 +436,7 @@ export const MessageToolbar = ({
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
className,
)}
{...props}
>

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}
/>