chore: remove files importing pruned packages (ai, cms, cognitive-context)
Step 3 pruned packages/{ai,cms,cognitive-context} but left whole
route groups + feature modules that depended on them. Those files
were unbuildable since that prune. Removes them now so the workspace
can be validated:
Route groups:
- apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/
- apps/web/src/app/[locale]/(marketing)/blog/
Feature modules:
- apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/
- packages/api/src/modules/ai/ (chat, image, pdf, stt, tts, router)
3 stragglers remain (separate handoff to claudemesh-2):
- apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx (cms)
- apps/web/src/app/sitemap.ts (cms)
- apps/web/src/modules/common/layout/credits/index.tsx (ai)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,227 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { DropzoneOptions, DropzoneState } from "react-dropzone";
|
||||
|
||||
const DropzoneContext = createContext<{
|
||||
dropzone: DropzoneState;
|
||||
} | null>(null);
|
||||
|
||||
interface DropzoneProps extends DropzoneOptions {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => {
|
||||
const dropzone = useDropzone({
|
||||
accept: {
|
||||
"image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"],
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
multiple: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider value={{ dropzone }}>
|
||||
<div {...dropzone.getRootProps()} className="relative h-full w-full">
|
||||
{children}
|
||||
|
||||
<AnimatePresence>
|
||||
{dropzone.isDragActive && dialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="bg-background/50 absolute inset-0 backdrop-blur-sm md:rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
|
||||
{dialog}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = memo<React.ButtonHTMLAttributes<HTMLButtonElement>>((props) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const context = useContext(DropzoneContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...context?.dropzone.getInputProps()}
|
||||
disabled={props.disabled ?? false}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 rounded-full dark:bg-transparent",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
context?.dropzone.open();
|
||||
props.onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<Icons.Paperclip className="size-4" />
|
||||
<span className="sr-only">{t("chat.composer.files.add")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("chat.composer.files.add")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
attachments: File[];
|
||||
onRemove: (file: File) => void;
|
||||
}
|
||||
|
||||
export const Preview = memo<PreviewProps>(
|
||||
({ attachments, onRemove, className, ...props }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"-mb-2.5 flex w-full flex-wrap gap-3 px-2 pt-4 @[480px]/input:px-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Thumbnail
|
||||
key={attachment.name}
|
||||
attachment={attachment}
|
||||
onRemove={() => onRemove(attachment)}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments.map((attachment) => ({
|
||||
url: URL.createObjectURL(attachment),
|
||||
}))}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Preview.displayName = "Preview";
|
||||
|
||||
interface ThumbnailProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
attachment: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const Thumbnail = memo<ThumbnailProps>(({ attachment, onRemove, ...props }) => {
|
||||
const { t } = useTranslation(["ai"]);
|
||||
const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button {...props} type="button">
|
||||
<Avatar className="size-16 shrink-0 rounded-xl">
|
||||
<AvatarImage
|
||||
src={preview}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
className="rounded-xl border object-cover"
|
||||
/>
|
||||
<AvatarFallback className="rounded-xl">
|
||||
<Icons.Image className="text-muted-foreground size-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span className="sr-only">{t("chat.composer.files.preview")}</span>
|
||||
</button>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card dark:bg-card absolute top-0 right-0 size-5 translate-x-1/3 -translate-y-1/3 p-1"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<Icons.X className="size-full" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{t("chat.composer.files.remove")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Thumbnail.displayName = "Thumbnail";
|
||||
|
||||
export const Attachments = {
|
||||
Input,
|
||||
Dropzone,
|
||||
Preview,
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { TextareaAutosize } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { Attachments } from "./attachments";
|
||||
|
||||
const Form = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) => {
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
ref.current
|
||||
?.closest("main")
|
||||
?.style.setProperty(
|
||||
"--composer-height",
|
||||
`${entry.contentRect.height}px`,
|
||||
);
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative bottom-0 z-10 flex w-full flex-col items-center justify-center gap-2 text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/65 ring-border/75 focus-within:ring-input hover:ring-input hover:focus-within:ring-input @container/input relative w-full max-w-200 rounded-2xl px-2 pb-2 ring-1 backdrop-blur-xl duration-100 ring-inset focus-within:ring-1 @lg:rounded-3xl @lg:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Textarea = ({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "style">) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"text-foreground mb-3 min-h-20 w-full resize-none bg-transparent px-2 pt-5 align-bottom focus:outline-none @[480px]/input:px-3",
|
||||
className,
|
||||
)}
|
||||
spellCheck={false}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Composer = {
|
||||
Form,
|
||||
Input,
|
||||
Textarea,
|
||||
Attachments,
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { ProviderIcons } from "~/modules/common/ai/icons";
|
||||
|
||||
import type { Provider } from "@turbostarter/ai";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ModelSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: Provider;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ModelSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: ModelSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map((option) => {
|
||||
const Icon = ProviderIcons[option.provider];
|
||||
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="text-foreground size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Provider } from "@turbostarter/ai";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const ProviderIcons = {
|
||||
[Provider.OPENAI]: Icons.OpenAI,
|
||||
[Provider.GEMINI]: Icons.Gemini,
|
||||
[Provider.CLAUDE]: Icons.Claude,
|
||||
[Provider.GROK]: Icons.Grok,
|
||||
[Provider.DEEPSEEK]: Icons.DeepSeek,
|
||||
[Provider.REPLICATE]: Icons.Replicate,
|
||||
[Provider.LUMA]: Icons.Luma,
|
||||
[Provider.STABILITY_AI]: Icons.StabilityAI,
|
||||
[Provider.RECRAFT]: Icons.Recraft,
|
||||
[Provider.ELEVEN_LABS]: Icons.ElevenLabs,
|
||||
[Provider.NVIDIA]: Icons.Nvidia,
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { TextShimmer } from "@turbostarter/ui-web/text-shimmer";
|
||||
|
||||
import type { Transition } from "motion/react";
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 2.5,
|
||||
ease: [0.175, 0.885, 0.32, 1],
|
||||
times: [0, 0.6, 0.6, 1],
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 0.2,
|
||||
};
|
||||
|
||||
export const AnalyzingImage = () => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative isolate flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{
|
||||
clipPath: "inset(0px 0px 0px 0px)",
|
||||
}}
|
||||
animate={{
|
||||
clipPath: [
|
||||
"inset(0px 0px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 0px 0px 0px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-background z-10"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M4.27209 20.7279L10.8686 14.1314C11.2646 13.7354 11.4627 13.5373 11.691 13.4632C11.8918 13.3979 12.1082 13.3979 12.309 13.4632C12.5373 13.5373 12.7354 13.7354 13.1314 14.1314L19.6839 20.6839M14 15L16.8686 12.1314C17.2646 11.7354 17.4627 11.5373 17.691 11.4632C17.8918 11.3979 18.1082 11.3979 18.309 11.4632C18.5373 11.5373 18.7354 11.7354 19.1314 12.1314L22 15M10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9ZM6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ transform: "translateX(10px)" }}
|
||||
animate={{
|
||||
transform: [
|
||||
"translateX(10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(10px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-muted-foreground/65 absolute z-10 h-full w-[3px] rounded-full"
|
||||
/>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65 absolute"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="9" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="15" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="18" width="2" height="1" fill="currentColor" />
|
||||
<rect x="5" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="4" width="2" height="1" fill="currentColor" />
|
||||
<rect x="3" y="9" width="1" height="3" fill="currentColor" />
|
||||
<rect x="4" y="10" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="8" width="1" height="3" fill="currentColor" />
|
||||
<rect x="19" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="16" y="12" width="1" height="2" fill="currentColor" />
|
||||
<rect x="13" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="10" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="4" width="3" height="1" fill="currentColor" />
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="14" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="14" width="3" height="1" fill="currentColor" />
|
||||
<rect x="16" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="16" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="16" width="2" height="1" fill="currentColor" />
|
||||
<rect x="14" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="5" width="3" height="1" fill="currentColor" />
|
||||
<rect x="12" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="7" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="12" width="2" height="1" fill="currentColor" />
|
||||
<rect x="19" y="13" width="2" height="1" fill="currentColor" />
|
||||
<rect x="9" y="6" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="19" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="3" y="15" width="1" height="2" fill="currentColor" />
|
||||
<rect x="3" y="19" width="1" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<TextShimmer className="text-sm font-medium" duration={1.5}>
|
||||
{t("analyzingImage")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { getMessageTextContent } from "@turbostarter/ai";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useCopy } from "~/modules/common/hooks/use-copy";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
const transition = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
transition: { duration: 0.1, ease: "easeInOut" as const },
|
||||
};
|
||||
|
||||
interface ThreadMessageCopyProps<MESSAGE extends UIMessage = UIMessage> {
|
||||
message: MESSAGE;
|
||||
}
|
||||
|
||||
export const ThreadMessageCopy = <MESSAGE extends UIMessage = UIMessage>({
|
||||
message,
|
||||
}: ThreadMessageCopyProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => copy(getMessageTextContent(message))}
|
||||
>
|
||||
<div className="relative size-3.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className="sr-only">{t("copy")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("copy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { ThreadMessageCopy } from "./copy";
|
||||
import { ThreadMessageLikes } from "./likes";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ControlsProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
export const Controls = ({ message }: ControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3",
|
||||
)}
|
||||
>
|
||||
{message.parts.some(
|
||||
(part) => part.type === "text" && part.text.length > 0,
|
||||
) && <ThreadMessageCopy message={message} />}
|
||||
<ThreadMessageLikes />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
export const ThreadMessageLikes = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [likeState, setLikeState] = useState<-1 | 0 | 1>(0);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === 1 ? 0 : 1)}
|
||||
>
|
||||
<Icons.ThumbsUp
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === 1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("like")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("like")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === -1 ? 0 : -1)}
|
||||
>
|
||||
<Icons.ThumbsDown
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === -1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("dislike")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("dislike")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface UseThreadLayoutProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
}
|
||||
|
||||
export const useThreadLayout = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
}: UseThreadLayoutProps<MESSAGE>) => {
|
||||
const [scrolledByUser, setScrolledByUser] = useState(false);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessageRef = useRef<HTMLDivElement>(null);
|
||||
const isChatActive = initialMessages?.length !== messages.length;
|
||||
|
||||
const lastUserMessageIndex = [...messages]
|
||||
.reverse()
|
||||
.findIndex((m) => m.role === Role.USER);
|
||||
const lastResponseMessages = messages.slice(
|
||||
lastUserMessageIndex !== 0 ? -2 : -1,
|
||||
);
|
||||
const previousMessages = messages.slice(0, -lastResponseMessages.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrolledByUser(true);
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
setScrolledByUser(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
parent?.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
parent?.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [lastMessageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
|
||||
const isAtBottom = () => {
|
||||
const container = parent?.closest("[data-radix-scroll-area-viewport]");
|
||||
|
||||
if (!container) return false;
|
||||
|
||||
const scrollBottom = container.scrollTop + container.clientHeight;
|
||||
return Math.abs(container.scrollHeight - scrollBottom) < 150;
|
||||
};
|
||||
|
||||
if (isChatActive) {
|
||||
if (lastMessage?.role === Role.USER) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
} else if (isAtBottom() && !scrolledByUser) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [lastMessage, scrolledByUser, isChatActive]);
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
lastResponseMessages,
|
||||
previousMessages,
|
||||
};
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { AnalyzingImage } from "./analyzing-image";
|
||||
import { useThreadLayout } from "./hooks/use-thread-layout";
|
||||
import { ThreadMessage } from "./message";
|
||||
|
||||
import type { ThreadMessageComponents } from "./message";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ThreadProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
readonly status: string;
|
||||
readonly error?: Error | null;
|
||||
readonly regenerate?: () => Promise<void>;
|
||||
readonly className?: string;
|
||||
readonly components: ThreadMessageComponents<MESSAGE>;
|
||||
readonly footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
status,
|
||||
error,
|
||||
regenerate,
|
||||
className,
|
||||
components,
|
||||
footer,
|
||||
}: ThreadProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const isReloading = useRef(false);
|
||||
|
||||
const {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
previousMessages,
|
||||
lastResponseMessages,
|
||||
} = useThreadLayout({ messages, initialMessages });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
status === "ready" &&
|
||||
!isReloading.current
|
||||
) {
|
||||
isReloading.current = true;
|
||||
void regenerate?.().finally(() => {
|
||||
isReloading.current = false;
|
||||
});
|
||||
}
|
||||
}, [regenerate, messages, status]);
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: MESSAGE) => {
|
||||
return (
|
||||
<ThreadMessage.Message
|
||||
message={message}
|
||||
key={message.id}
|
||||
status={status}
|
||||
components={components}
|
||||
{...(message.id === lastMessage?.id && { ref: lastMessageRef })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[lastMessage?.id, lastMessageRef, status, components],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"@container/thread h-full w-full pt-12 pb-4 md:pt-14",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
{previousMessages.map(renderMessage)}
|
||||
<div
|
||||
className={cn("mx-auto flex w-full max-w-3xl flex-col", {
|
||||
"min-h-[calc(100vh-4rem)] md:min-h-[calc(100vh-5.5rem)]":
|
||||
isChatActive,
|
||||
})}
|
||||
>
|
||||
{lastResponseMessages.map(renderMessage)}
|
||||
{["submitted", "streaming"].includes(status) && (
|
||||
<div className="relative py-4 md:px-4">
|
||||
{status === "submitted" &&
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
messages
|
||||
.at(-1)
|
||||
?.parts.some(
|
||||
(part) =>
|
||||
part.type === "file" && part.mediaType.startsWith("image"),
|
||||
) ? (
|
||||
<AnalyzingImage />
|
||||
) : (
|
||||
<Icons.Loader className="text-muted-foreground size-5 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{footer}
|
||||
{error && (
|
||||
<div className="relative pb-4 @lg/thread:px-2 @xl/thread:px-4">
|
||||
<div className="bg-destructive/10 dark:bg-destructive/40 flex w-fit flex-wrap items-center gap-3 rounded-xl p-5 py-3">
|
||||
<p className="text-destructive dark:text-foreground">
|
||||
{t("error.general")}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-auto gap-2"
|
||||
onClick={() => regenerate?.()}
|
||||
>
|
||||
<Icons.RotateCw className="size-4" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full pb-[calc(var(--composer-height)+20px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Controls } from "./controls";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
export type ThreadMessageComponents<MESSAGE extends UIMessage> = Record<
|
||||
string,
|
||||
React.ComponentType<ThreadMessageProps<MESSAGE>>
|
||||
>;
|
||||
|
||||
export interface ThreadMessageProps<T extends UIMessage = UIMessage> {
|
||||
readonly status: string;
|
||||
readonly message: T;
|
||||
readonly ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const Message = <MESSAGE extends UIMessage>(
|
||||
props: ThreadMessageProps<MESSAGE> & {
|
||||
components: ThreadMessageComponents<MESSAGE>;
|
||||
},
|
||||
) => {
|
||||
const role = props.message.role;
|
||||
|
||||
const isSupportedRole = (
|
||||
role: string,
|
||||
): role is keyof typeof props.components => {
|
||||
return role in props.components;
|
||||
};
|
||||
|
||||
if (!isSupportedRole(role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = props.components[role];
|
||||
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative mx-auto flex w-full max-w-3xl scroll-mb-[calc(var(--composer-height,140px)+36px)] flex-col justify-center gap-1 py-4 @md/thread:px-1 @lg/thread:px-2 @xl/thread:px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadMessage = {
|
||||
Layout,
|
||||
Message,
|
||||
Controls,
|
||||
};
|
||||
Reference in New Issue
Block a user