fix: title generation + ai sdk upgrade (#1392)
This commit is contained in:
parent
f19d3d4071
commit
9d5d8a3ea7
38 changed files with 1422 additions and 2360 deletions
|
|
@ -143,7 +143,7 @@ export const ChainOfThoughtStep = memo(
|
|||
>
|
||||
<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="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div
|
||||
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,7 +12,6 @@ export const Image = ({
|
|||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
// biome-ignore lint/nursery/useImageSize: dynamic base64 content
|
||||
// biome-ignore lint/performance/noImgElement: base64 data URLs require native img
|
||||
<img
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export const ModelSelectorLogoGroup = ({
|
|||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -154,8 +154,7 @@ export function PromptInputProvider({
|
|||
(FileUIPart & { id: string })[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// biome-ignore lint/suspicious/noEmptyBlockStatements: noop initializer
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
const openRef = useRef<() => void>(() => undefined);
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
|
|
@ -309,18 +308,18 @@ export function PromptInputAttachment({
|
|||
{...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="flex overflow-hidden absolute inset-0 justify-center items-center rounded transition-opacity size-5 bg-background group-hover:opacity-0">
|
||||
{isImage ? (
|
||||
/* biome-ignore lint/performance/noImgElement: dynamic user uploads */
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-5 object-cover"
|
||||
className="object-cover size-5"
|
||||
height={20}
|
||||
src={data.url}
|
||||
width={20}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
<div className="flex justify-center items-center size-5 text-muted-foreground">
|
||||
<PaperclipIcon className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -343,14 +342,14 @@ export function PromptInputAttachment({
|
|||
<span className="flex-1 truncate">{attachmentLabel}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<PromptInputHoverCardContent className="w-auto p-2">
|
||||
<div className="w-auto space-y-3">
|
||||
<PromptInputHoverCardContent className="p-2 w-auto">
|
||||
<div className="space-y-3 w-auto">
|
||||
{isImage && (
|
||||
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
|
||||
<div className="flex overflow-hidden justify-center items-center w-96 max-h-96 rounded-md border">
|
||||
{/* biome-ignore lint/performance/noImgElement: dynamic user uploads */}
|
||||
<img
|
||||
alt={filename || "attachment preview"}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
className="object-contain max-w-full max-h-full"
|
||||
height={384}
|
||||
src={data.url}
|
||||
width={448}
|
||||
|
|
@ -359,11 +358,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="text-sm font-semibold leading-none truncate">
|
||||
{filename || (isImage ? "Image" : "Attachment")}
|
||||
</h4>
|
||||
{data.mediaType && (
|
||||
<p className="truncate font-mono text-muted-foreground text-xs">
|
||||
<p className="font-mono text-xs truncate text-muted-foreground">
|
||||
{data.mediaType}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -395,7 +394,7 @@ export function PromptInputAttachments({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||||
className={cn("flex flex-wrap gap-2 items-center p-3 w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
|
|
@ -913,7 +912,7 @@ export const PromptInputTextarea = ({
|
|||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
className={cn("max-h-48 field-sizing-content min-h-16", className)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
|
|
@ -937,7 +936,7 @@ export const PromptInputHeader = ({
|
|||
}: PromptInputHeaderProps) => (
|
||||
<InputGroupAddon
|
||||
align="block-end"
|
||||
className={cn("order-first flex-wrap gap-1", className)}
|
||||
className={cn("flex-wrap order-first gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -953,7 +952,7 @@ export const PromptInputFooter = ({
|
|||
}: PromptInputFooterProps) => (
|
||||
<InputGroupAddon
|
||||
align="block-end"
|
||||
className={cn("justify-between gap-1", className)}
|
||||
className={cn("gap-1 justify-between", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -964,7 +963,7 @@ export const PromptInputTools = ({
|
|||
className,
|
||||
...props
|
||||
}: PromptInputToolsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
<div className={cn("flex gap-1 items-center", className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||
|
|
@ -1046,7 +1045,7 @@ export const PromptInputSubmit = ({
|
|||
let Icon = <CornerDownLeftIcon className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
Icon = <Loader2Icon className="animate-spin size-4" />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === "error") {
|
||||
|
|
@ -1111,7 +1110,6 @@ interface SpeechRecognitionErrorEvent extends Event {
|
|||
}
|
||||
|
||||
declare global {
|
||||
// biome-ignore lint/nursery/useConsistentTypeDefinitions: global augmentation requires interface
|
||||
interface Window {
|
||||
SpeechRecognition: {
|
||||
new (): SpeechRecognition;
|
||||
|
|
@ -1244,7 +1242,7 @@ export const PromptInputSelectTrigger = ({
|
|||
}: PromptInputSelectTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
|
||||
"font-medium bg-transparent border-none shadow-none transition-colors text-muted-foreground",
|
||||
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
||||
className
|
||||
)}
|
||||
|
|
@ -1332,7 +1330,7 @@ export const PromptInputTabLabel = ({
|
|||
}: PromptInputTabLabelProps) => (
|
||||
<h3
|
||||
className={cn(
|
||||
"mb-2 px-3 font-medium text-muted-foreground text-xs",
|
||||
"px-3 mb-2 text-xs font-medium text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -1356,7 +1354,7 @@ export const PromptInputTabItem = ({
|
|||
}: PromptInputTabItemProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
|
||||
"flex gap-2 items-center px-3 py-2 text-xs hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ export const QueueList = ({
|
|||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
|
|
@ -242,7 +242,7 @@ export const QueueSectionLabel = ({
|
|||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
|
|
@ -111,9 +110,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
|||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -132,15 +131,21 @@ export const ToolOutput = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
const renderOutput = () => {
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
return (
|
||||
<pre className="overflow-x-auto p-3 font-mono text-xs">
|
||||
{JSON.stringify(output, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (typeof output === "string") {
|
||||
return (
|
||||
<pre className="overflow-x-auto p-3 font-mono text-xs">{output}</pre>
|
||||
);
|
||||
}
|
||||
return <div className="p-3">{output as ReactNode}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
|
|
@ -155,8 +160,8 @@ export const ToolOutput = ({
|
|||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
{errorText && <div className="p-3">{errorText}</div>}
|
||||
{!errorText && renderOutput()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,13 +89,9 @@ export function Chat({
|
|||
} = useChat<ChatMessage>({
|
||||
id,
|
||||
messages: initialMessages,
|
||||
experimental_throttle: 100,
|
||||
generateId: generateUUID,
|
||||
// Auto-continue after tool approval (only for APPROVED tools)
|
||||
// Denied tools don't need server continuation - state is saved on next user message
|
||||
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
|
||||
const lastMessage = currentMessages.at(-1);
|
||||
// Only continue if a tool was APPROVED (not denied)
|
||||
const shouldContinue =
|
||||
lastMessage?.parts?.some(
|
||||
(part) =>
|
||||
|
|
@ -111,10 +107,6 @@ export function Chat({
|
|||
fetch: fetchWithErrorHandlers,
|
||||
prepareSendMessagesRequest(request) {
|
||||
const lastMessage = request.messages.at(-1);
|
||||
|
||||
// Check if this is a tool approval continuation:
|
||||
// - Last message is NOT a user message (meaning no new user input)
|
||||
// - OR any message has tool parts that were responded to (approved or denied)
|
||||
const isToolApprovalContinuation =
|
||||
lastMessage?.role !== "user" ||
|
||||
request.messages.some((msg) =>
|
||||
|
|
@ -129,7 +121,6 @@ export function Chat({
|
|||
return {
|
||||
body: {
|
||||
id: request.id,
|
||||
// Send all messages for tool approval continuation, otherwise just the last user message
|
||||
...(isToolApprovalContinuation
|
||||
? { messages: request.messages }
|
||||
: { message: lastMessage }),
|
||||
|
|
@ -148,7 +139,6 @@ export function Chat({
|
|||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ChatSDKError) {
|
||||
// Check if it's a credit card error
|
||||
if (
|
||||
error.message?.includes("AI Gateway requires a valid credit card")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
{consoleOutput.contents.map((content, contentIndex) =>
|
||||
content.type === "image" ? (
|
||||
<picture key={`${consoleOutput.id}-${contentIndex}`}>
|
||||
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
|
||||
<img
|
||||
alt="output"
|
||||
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
className="overflow-hidden dark:hidden"
|
||||
codeTagProps={{
|
||||
className: "font-mono text-sm",
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
overflowX: "auto",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
paddingRight: "1rem",
|
||||
minWidth: "2.5rem",
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneLight}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
<SyntaxHighlighter
|
||||
className="hidden overflow-hidden dark:block"
|
||||
codeTagProps={{
|
||||
className: "font-mono text-sm",
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
overflowX: "auto",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
paddingRight: "1rem",
|
||||
minWidth: "2.5rem",
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneDark}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -49,7 +49,7 @@ export const ConversationScrollButton = ({
|
|||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg",
|
||||
"absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-lg",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ export const Image = ({
|
|||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size"
|
||||
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
|
||||
// biome-ignore lint/performance/noImgElement: base64 data URLs require native img
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
|
|
|
|||
|
|
@ -50,22 +50,25 @@ export const PromptInputTextarea = ({
|
|||
}: PromptInputTextareaProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
// Don't submit if IME composition is in progress
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter (without Shift)
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
const submitButton = form?.querySelector(
|
||||
'button[type="submit"]'
|
||||
) as HTMLButtonElement | null;
|
||||
if (submitButton?.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = ({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
export function Response({ className, children, ...props }: ResponseProps) {
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
|
|
@ -111,9 +110,9 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
|||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export function ImageEditor({
|
|||
</div>
|
||||
) : (
|
||||
<picture>
|
||||
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
|
||||
<img
|
||||
alt={title}
|
||||
className={cn("h-fit w-full max-w-[800px]", {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function PureMessageActions({
|
|||
<div className="relative">
|
||||
{setMode && (
|
||||
<Action
|
||||
className="-left-10 absolute top-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
data-testid="message-edit-button"
|
||||
onClick={() => setMode("edit")}
|
||||
tooltip="Edit"
|
||||
|
|
|
|||
|
|
@ -108,14 +108,18 @@ const PurePreviewMessage = ({
|
|||
const { type } = part;
|
||||
const key = `message-${message.id}-part-${index}`;
|
||||
|
||||
if (type === "reasoning" && part.text?.trim().length > 0) {
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading}
|
||||
key={key}
|
||||
reasoning={part.text}
|
||||
/>
|
||||
);
|
||||
if (type === "reasoning") {
|
||||
const hasContent = part.text?.trim().length > 0;
|
||||
const isStreaming = "state" in part && part.state === "streaming";
|
||||
if (hasContent || isStreaming) {
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading || isStreaming}
|
||||
key={key}
|
||||
reasoning={part.text || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ function PureMessages({
|
|||
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
isAtBottom
|
||||
? "pointer-events-none scale-0 opacity-0"
|
||||
: "pointer-events-auto scale-100 opacity-100"
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ function PureMultimodalInput({
|
|||
)}
|
||||
|
||||
<input
|
||||
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
|
||||
className="pointer-events-none fixed -top-4 -left-4 size-0.5 opacity-0"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
|
|
@ -320,6 +320,9 @@ function PureMultimodalInput({
|
|||
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!input.trim() && attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (status !== "ready") {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const Suggestion = ({
|
|||
{isExpanded ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: -20 }}
|
||||
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
|
||||
className="absolute -right-12 z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl md:-right-16"
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key={suggestion.id}
|
||||
|
|
@ -61,7 +61,7 @@ export const Suggestion = ({
|
|||
) : (
|
||||
<motion.div
|
||||
className={cn("cursor-pointer p-1 text-muted-foreground", {
|
||||
"-right-8 absolute": artifactKind === "text",
|
||||
"absolute -right-8": artifactKind === "text",
|
||||
"sticky top-0 right-4": artifactKind === "code",
|
||||
})}
|
||||
onClick={() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue