feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
apps/web/src/modules/chat/composer/components/voice-button.tsx
Normal file
125
apps/web/src/modules/chat/composer/components/voice-button.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import type { VoiceButtonProps } from "../types";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const VoiceLevelBars = ({ level }: { level: number }) => {
|
||||
// Create 3 bars with different thresholds
|
||||
const bars = [
|
||||
{ threshold: 10, delay: "0ms" },
|
||||
{ threshold: 30, delay: "100ms" },
|
||||
{ threshold: 50, delay: "200ms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
{bars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-0.5 bg-white rounded-full transition-all duration-150",
|
||||
level > bar.threshold ? "opacity-100" : "opacity-30"
|
||||
)}
|
||||
style={{
|
||||
height: level > bar.threshold ? `${Math.min(12, 4 + (level / 100) * 8)}px` : "4px",
|
||||
animationDelay: bar.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceButton = ({
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
onCancel: _onCancel,
|
||||
}: VoiceButtonProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const isRecording = state === "recording";
|
||||
const isProcessing = state === "processing";
|
||||
|
||||
const getTooltipContent = () => {
|
||||
if (isRecording) {
|
||||
return t("pressEscapeToCancel");
|
||||
}
|
||||
if (isProcessing) {
|
||||
return t("transcribing");
|
||||
}
|
||||
return t("record");
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative">
|
||||
{/* Recording state indicator - shows duration and level */}
|
||||
{isRecording && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-destructive text-destructive-foreground px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
|
||||
</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
<VoiceLevelBars level={audioLevel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"shrink-0 rounded-full transition-all duration-200",
|
||||
isRecording && "bg-destructive hover:bg-destructive/90 text-destructive-foreground animate-pulse-ring",
|
||||
isProcessing && "opacity-70"
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isRecording ? "destructive" : "ghost"}
|
||||
onClick={onToggle}
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
<span className="sr-only">{t("transcribing")}</span>
|
||||
</>
|
||||
) : isRecording ? (
|
||||
<>
|
||||
<Icons.Square className="size-3.5 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Mic className="size-4" />
|
||||
<span className="sr-only">{t("record")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{getTooltipContent()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceButton;
|
||||
56
apps/web/src/modules/chat/composer/dropzone.tsx
Normal file
56
apps/web/src/modules/chat/composer/dropzone.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { Attachments } from "~/modules/common/ai/composer/attachments";
|
||||
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
|
||||
const DropzoneDialog = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background relative z-10 mx-6 flex flex-col items-center justify-center rounded-xl border p-6 py-8 sm:p-8 sm:py-10 md:px-12 md:py-10"
|
||||
initial={{ opacity: 0, translateY: 10 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Icons.ImagePlus className="text-muted-foreground size-12" />
|
||||
<span className="mt-3 text-lg font-medium">
|
||||
{t("chat.composer.files.dropzone.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{t("chat.composer.files.dropzone.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatDropzoneProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ChatDropzone = memo<ChatDropzoneProps>(
|
||||
({ children, disabled }) => {
|
||||
const { onAdd } = useAttachments();
|
||||
|
||||
return (
|
||||
<Attachments.Dropzone
|
||||
onDrop={onAdd}
|
||||
dialog={<DropzoneDialog />}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Attachments.Dropzone>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChatDropzone.displayName = "ChatDropzone";
|
||||
148
apps/web/src/modules/chat/composer/hooks/use-attachments.tsx
Normal file
148
apps/web/src/modules/chat/composer/hooks/use-attachments.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { uploadWithRetry } from "~/utils";
|
||||
|
||||
const MAX_FILE_SIZE_IN_MB = 5;
|
||||
const MAX_FILE_SIZE = MAX_FILE_SIZE_IN_MB * 1024 * 1024;
|
||||
const MAX_FILES_COUNT = 5;
|
||||
const ACCEPTED_FILE_TYPES = [
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
];
|
||||
|
||||
const useValidation = () => {
|
||||
const { t } = useTranslation(["validation"]);
|
||||
|
||||
const fileSchema = z
|
||||
.instanceof(File)
|
||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), {
|
||||
message: t("error.file.type", {
|
||||
type: "image",
|
||||
}),
|
||||
})
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, {
|
||||
message: t("error.tooBig.file.notInclusive", {
|
||||
size: MAX_FILE_SIZE_IN_MB,
|
||||
}),
|
||||
});
|
||||
|
||||
const validate = (files: File[], attachments: File[]) => {
|
||||
const errors = new Set<string>();
|
||||
Array.from(files).forEach((file) => {
|
||||
try {
|
||||
fileSchema.parse(file);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && error.issues[0]) {
|
||||
errors.add(error.issues[0].message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (files.length + attachments.length > MAX_FILES_COUNT) {
|
||||
errors.add(
|
||||
t("error.file.maxCount", {
|
||||
count: MAX_FILES_COUNT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
files: files
|
||||
.filter((file) => fileSchema.safeParse(file).success)
|
||||
.slice(0, MAX_FILES_COUNT - attachments.length)
|
||||
.map((file) => new File([file], generateId(), { type: file.type })),
|
||||
};
|
||||
};
|
||||
|
||||
return { validate };
|
||||
};
|
||||
|
||||
interface AttachmentsState {
|
||||
attachments: File[];
|
||||
setAttachments: (attachments: File[]) => void;
|
||||
}
|
||||
|
||||
export const useAttachmentsStore = create<AttachmentsState>((set) => ({
|
||||
attachments: [],
|
||||
setAttachments: (attachments) => set({ attachments }),
|
||||
}));
|
||||
|
||||
export const useAttachments = () => {
|
||||
const { validate } = useValidation();
|
||||
const { attachments, setAttachments } = useAttachmentsStore();
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async ({ directory }: { directory: string }) => {
|
||||
setAttachments([]);
|
||||
await Promise.allSettled(
|
||||
attachments.map((attachment) =>
|
||||
uploadWithRetry({
|
||||
path: `${directory}/${attachment.name}.${
|
||||
attachment.type.split("/")[1] ?? "png"
|
||||
}`,
|
||||
file: attachment,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const onAdd = useCallback(
|
||||
(files: File[]) => {
|
||||
const { errors, files: filesToAdd } = validate(files, attachments);
|
||||
|
||||
for (const error of errors) {
|
||||
toast.error(error);
|
||||
}
|
||||
|
||||
if (!filesToAdd.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachments([...attachments, ...filesToAdd]);
|
||||
},
|
||||
[attachments, setAttachments, validate],
|
||||
);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(file: File) => {
|
||||
setAttachments(attachments.filter((a) => a.name !== file.name));
|
||||
},
|
||||
[attachments, setAttachments],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData.items;
|
||||
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
onAdd(files);
|
||||
}
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
setAttachments([]);
|
||||
}, [setAttachments]);
|
||||
|
||||
return { attachments, upload, onAdd, onRemove, onPaste, onClear };
|
||||
};
|
||||
209
apps/web/src/modules/chat/composer/hooks/use-composer.tsx
Normal file
209
apps/web/src/modules/chat/composer/hooks/use-composer.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Chat } from "@ai-sdk/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { chatMessageOptionsSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { chat as chatApi } from "~/modules/chat/lib/api";
|
||||
import { useAIError } from "~/modules/common/hooks/use-ai-error";
|
||||
import { useCredits } from "~/modules/common/layout/credits";
|
||||
|
||||
import { useAttachments } from "./use-attachments";
|
||||
|
||||
import type { ChatMessageOptionsPayload } from "@turbostarter/ai/chat/schema";
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface ChatOptionsState {
|
||||
options: ChatMessageOptionsPayload;
|
||||
setOptions: (options: Partial<ChatMessageOptionsPayload>) => void;
|
||||
}
|
||||
|
||||
export const useChatOptions = create<ChatOptionsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
options: {
|
||||
reason: false,
|
||||
search: false,
|
||||
model: MODELS[0].id,
|
||||
},
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
...options,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "chat-options",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const chats = new Map<string, Chat<ChatMessage>>();
|
||||
|
||||
const getChatInstance = ({
|
||||
id,
|
||||
...options
|
||||
}: ConstructorParameters<typeof Chat<ChatMessage>>[0]) => {
|
||||
if (!id || !chats.has(id)) {
|
||||
const chat = new Chat<ChatMessage>({
|
||||
id,
|
||||
...options,
|
||||
});
|
||||
|
||||
chats.set(id ?? chat.id, chat);
|
||||
}
|
||||
|
||||
const instance = chats.get(id ?? "");
|
||||
if (!instance) {
|
||||
throw new Error(`Chat instance with id ${id} not found!`);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
interface UseComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const useComposer = ({ id, initialMessages }: UseComposerProps = {}) => {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const { onError } = useAIError();
|
||||
const { invalidate } = useCredits();
|
||||
const { data } = authClient.useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { options, setOptions } = useChatOptions();
|
||||
const { attachments, upload, onClear } = useAttachments();
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(chatMessageOptionsSchema),
|
||||
defaultValues: options,
|
||||
});
|
||||
|
||||
const contextForm = useFormContext<ChatMessageOptionsPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
const chat = getChatInstance({
|
||||
id,
|
||||
transport: new DefaultChatTransport({
|
||||
api: api.ai.chat.chats.$url().toString(),
|
||||
prepareSendMessagesRequest: ({ messages, id }) => {
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
const directory = `attachments/${id}/${lastMessage?.id}`;
|
||||
|
||||
upload.mutate({
|
||||
directory,
|
||||
});
|
||||
|
||||
return {
|
||||
body: {
|
||||
...lastMessage,
|
||||
chatId: id,
|
||||
parts: lastMessage?.parts.map((part) =>
|
||||
part.type === "file"
|
||||
? {
|
||||
...part,
|
||||
path: `${directory}/${part.filename}.${part.mediaType.split("/")[1] ?? "png"}`,
|
||||
}
|
||||
: part,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
messages: initialMessages,
|
||||
onFinish: () => {
|
||||
void invalidate();
|
||||
if (!initialMessages?.length) {
|
||||
void queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(data?.user.id ?? ""),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const { messages, sendMessage, ...rest } = useChat({
|
||||
chat,
|
||||
});
|
||||
|
||||
const syncOptions: WatchObserver<ChatMessageOptionsPayload> = useCallback(
|
||||
(values) => setOptions(values),
|
||||
[setOptions],
|
||||
);
|
||||
|
||||
const debouncedSyncOptions = useDebounceCallback(syncOptions, 500);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSyncOptions);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSyncOptions]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(prompt?: string) => {
|
||||
const url = pathsConfig.apps.chat.chat(chat.id);
|
||||
|
||||
window.history.replaceState({}, "", url);
|
||||
|
||||
if (prompt) {
|
||||
return sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const dataTransfer = new DataTransfer();
|
||||
attachments.forEach((attachment) => {
|
||||
dataTransfer.items.add(attachment);
|
||||
});
|
||||
|
||||
void sendMessage({
|
||||
text: input,
|
||||
files: dataTransfer.files,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
setInput("");
|
||||
}
|
||||
},
|
||||
[sendMessage, input, attachments, chat.id, form],
|
||||
);
|
||||
|
||||
const model = MODELS.find((model) => model.id === form.watch("model"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model?.attachments) {
|
||||
onClear();
|
||||
}
|
||||
}, [model?.attachments, onClear]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
form,
|
||||
onSubmit,
|
||||
input,
|
||||
setInput,
|
||||
model,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
261
apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx
Normal file
261
apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
import type {
|
||||
UseVoiceRecordingOptions,
|
||||
UseVoiceRecordingReturn,
|
||||
VoiceRecordingState,
|
||||
} from "../types";
|
||||
|
||||
export const useVoiceRecording = (
|
||||
options: UseVoiceRecordingOptions = {}
|
||||
): UseVoiceRecordingReturn => {
|
||||
const { onTranscription, onError, onStateChange } = options;
|
||||
|
||||
const [state, setState] = useState<VoiceRecordingState>("idle");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update state and notify
|
||||
const updateState = useCallback(
|
||||
(newState: VoiceRecordingState) => {
|
||||
setState(newState);
|
||||
onStateChange?.(newState);
|
||||
},
|
||||
[onStateChange]
|
||||
);
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setDuration(0);
|
||||
setAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
// Monitor audio levels
|
||||
const monitorAudioLevel = useCallback(() => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average volume (0-100)
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||
const normalizedLevel = Math.min(100, Math.round((average / 255) * 100 * 2));
|
||||
setAudioLevel(normalizedLevel);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(monitorAudioLevel);
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
cleanup();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
// Setup audio analysis
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// Start audio level monitoring
|
||||
monitorAudioLevel();
|
||||
|
||||
// Setup media recorder
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: "audio/mp4",
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Stop level monitoring
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
console.log("[Voice] Recording stopped, chunks:", chunksRef.current.length);
|
||||
|
||||
if (chunksRef.current.length === 0) {
|
||||
console.log("[Voice] No chunks recorded, aborting");
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
updateState("processing");
|
||||
|
||||
try {
|
||||
const audioBlob = new Blob(chunksRef.current, {
|
||||
type: mediaRecorder.mimeType,
|
||||
});
|
||||
|
||||
console.log("[Voice] Audio blob:", audioBlob.size, "bytes,", mediaRecorder.mimeType);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"audio",
|
||||
audioBlob,
|
||||
`recording.${mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4"}`
|
||||
);
|
||||
|
||||
const url = api.ai.stt.$url().toString();
|
||||
console.log("[Voice] Sending to:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
console.log("[Voice] Response status:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error("[Voice] Error response:", errorData);
|
||||
throw new Error(
|
||||
(errorData as { message?: string }).message ??
|
||||
"Transcription failed"
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { text: string };
|
||||
console.log("[Voice] Transcription result:", result.text);
|
||||
onTranscription?.(result.text);
|
||||
} catch (err) {
|
||||
console.error("[Voice] Error:", err);
|
||||
const transcriptionError =
|
||||
err instanceof Error ? err : new Error("Transcription failed");
|
||||
setError(transcriptionError);
|
||||
onError?.(transcriptionError);
|
||||
} finally {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
updateState("recording");
|
||||
|
||||
// Start duration timer
|
||||
setDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const accessError =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to access microphone");
|
||||
setError(accessError);
|
||||
onError?.(accessError);
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
}, [cleanup, monitorAudioLevel, onTranscription, onError, updateState]);
|
||||
|
||||
// Stop recording (will trigger transcription)
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cancel recording (no transcription)
|
||||
const cancelRecording = useCallback(() => {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}, [cleanup, updateState]);
|
||||
|
||||
// Toggle recording
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (state === "recording") {
|
||||
stopRecording();
|
||||
} else if (state === "idle") {
|
||||
void startRecording();
|
||||
}
|
||||
}, [state, startRecording, stopRecording]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && state === "recording") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [state, cancelRecording]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
error,
|
||||
isRecording: state === "recording",
|
||||
isProcessing: state === "processing",
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
toggleRecording,
|
||||
};
|
||||
};
|
||||
185
apps/web/src/modules/chat/composer/index.tsx
Normal file
185
apps/web/src/modules/chat/composer/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Toggle } from "@turbostarter/ui-web/toggle";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { VoiceButton } from "./components/voice-button";
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
import { useComposer } from "./hooks/use-composer";
|
||||
import { useVoiceRecording } from "./hooks/use-voice-recording";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ChatComposer = ({
|
||||
id,
|
||||
initialMessages,
|
||||
}: ChatComposerProps = {}) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { status, stop, form, onSubmit, model, input, setInput } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
const { attachments, onRemove, onPaste } = useAttachments();
|
||||
|
||||
const {
|
||||
state: voiceState,
|
||||
duration,
|
||||
audioLevel,
|
||||
toggleRecording,
|
||||
cancelRecording,
|
||||
} = useVoiceRecording({
|
||||
onTranscription: (text) => {
|
||||
setInput((prev) => (prev ? `${prev} ${text}` : text));
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error.message.includes("microphone")
|
||||
? t("microphoneDenied", { ns: "common" })
|
||||
: t("transcriptionFailed", { ns: "common" });
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = ["submitted", "streaming"].includes(status);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Composer.Form onSubmit={form.handleSubmit(() => onSubmit())}>
|
||||
<Composer.Input className="pb-12">
|
||||
<Composer.Attachments.Preview
|
||||
attachments={attachments}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<Composer.Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
maxLength={5_000}
|
||||
placeholder={t("chat.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isSubmitting) {
|
||||
return form.handleSubmit(() => onSubmit())();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<Composer.Attachments.Input disabled={!model?.attachments} />
|
||||
|
||||
<div className="flex max-w-full grow gap-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.tools && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.tools}
|
||||
>
|
||||
<Icons.Globe className="size-4 shrink-0" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("search.label")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.reason && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.reason}
|
||||
>
|
||||
<Icons.Sparkle className="size-4" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("reason")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<VoiceButton
|
||||
state={voiceState}
|
||||
duration={duration}
|
||||
audioLevel={audioLevel}
|
||||
disabled={isSubmitting}
|
||||
onToggle={toggleRecording}
|
||||
onCancel={cancelRecording}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={!input.trim() && !isSubmitting}
|
||||
size="icon"
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (isSubmitting) {
|
||||
e.preventDefault();
|
||||
return stop();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icons.Square className="size-4 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.ArrowUp className="size-5" />
|
||||
<span className="sr-only">{t("send")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
38
apps/web/src/modules/chat/composer/types.ts
Normal file
38
apps/web/src/modules/chat/composer/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Voice Recording Types
|
||||
|
||||
export type VoiceRecordingState = "idle" | "recording" | "processing";
|
||||
|
||||
export interface VoiceRecordingData {
|
||||
state: VoiceRecordingState;
|
||||
duration: number; // seconds elapsed
|
||||
audioLevel: number; // 0-100 volume level
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStateChange?: (state: VoiceRecordingState) => void;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingReturn {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
error: Error | null;
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
toggleRecording: () => void;
|
||||
}
|
||||
|
||||
export interface VoiceButtonProps {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
25
apps/web/src/modules/chat/history/actions.tsx
Normal file
25
apps/web/src/modules/chat/history/actions.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface ChatActionsProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
|
||||
return (
|
||||
<CommandGroup heading={t("actions")}>
|
||||
<CommandItem asChild>
|
||||
<TurboLink href={pathsConfig.apps.chat.index} onClick={onSelect}>
|
||||
<Icons.SquarePen />
|
||||
<span>{t("chat.new")}</span>
|
||||
</TurboLink>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
97
apps/web/src/modules/chat/history/index.tsx
Normal file
97
apps/web/src/modules/chat/history/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
} from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { ChatActions } from "./actions";
|
||||
import { ChatHistoryList } from "./list";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface CommandMenuProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<CommandInput placeholder={t("chat.command.search")} />
|
||||
<CommandList className="h-[420px]">
|
||||
<CommandEmpty className="py-10">{t("chat.command.empty")}</CommandEmpty>
|
||||
<ChatActions onSelect={() => onOpenChange(false)} />
|
||||
<ChatHistoryList onSelect={() => onOpenChange(false)} />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group relative"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
|
||||
<span className="sr-only">{t("history")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("history")}</span>
|
||||
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<span className="">⌘</span>K
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/modules/chat/history/list/index.tsx
Normal file
57
apps/web/src/modules/chat/history/list/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDateGroups } from "@turbostarter/shared/hooks";
|
||||
import { CommandGroup } from "@turbostarter/ui-web/command";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { chat } from "../../lib/api";
|
||||
|
||||
import { ChatHistoryListItem } from "./item";
|
||||
|
||||
interface ChatHistoryListProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
const userChats = useQuery(
|
||||
chat.queries.chats.user.getAll(session?.user.id ?? ""),
|
||||
);
|
||||
|
||||
const groups = useDateGroups(userChats.data ?? []);
|
||||
|
||||
if (userChats.isLoading) {
|
||||
return (
|
||||
<CommandGroup heading={t("history")} className="w-full">
|
||||
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
|
||||
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
|
||||
<Skeleton className="h-11 w-1/2 rounded-xl" />
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(
|
||||
(group) =>
|
||||
group.items.length > 0 && (
|
||||
<CommandGroup heading={group.label} key={group.label}>
|
||||
{group.items.map((chat) => (
|
||||
<ChatHistoryListItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
167
apps/web/src/modules/chat/history/list/item.tsx
Normal file
167
apps/web/src/modules/chat/history/list/item.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { chat as chatApi } from "../../lib/api";
|
||||
|
||||
import type { Chat } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatHistoryListItemProps {
|
||||
readonly chat: Chat;
|
||||
readonly onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryListItem = ({
|
||||
chat,
|
||||
onSelect,
|
||||
}: ChatHistoryListItemProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={`${chat.id}-${chat.name}`}
|
||||
asChild
|
||||
onSelect={() => {
|
||||
router.push(pathsConfig.apps.chat.chat(chat.id));
|
||||
onSelect();
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<div>
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.chat.chat(chat.id)}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 grow items-center justify-start gap-3"
|
||||
>
|
||||
<Icons.MessagesSquare />
|
||||
<span className="min-w-0 truncate">{chat.name}</span>
|
||||
{pathname.includes(chat.id) && (
|
||||
<Badge variant="outline">{t("current")}</Badge>
|
||||
)}
|
||||
</TurboLink>
|
||||
<Controls chat={chat} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Controls = ({ chat }: { chat: Chat }) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const userId = session?.user.id ?? "";
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate } = useMutation({
|
||||
...chatApi.mutations.chats.delete,
|
||||
onMutate: async (data) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
});
|
||||
|
||||
const previousChats = queryClient.getQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
);
|
||||
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
|
||||
);
|
||||
|
||||
if (pathname.includes(chat.id)) {
|
||||
router.push(pathsConfig.apps.chat.index);
|
||||
}
|
||||
|
||||
return { previousChats };
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
toast.error(error.message);
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
context?.previousChats,
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(userId),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
|
||||
{dayjs(chat.createdAt).fromNow()}
|
||||
</span>
|
||||
|
||||
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(pathsConfig.apps.chat.chat(chat.id), "_blank");
|
||||
}}
|
||||
>
|
||||
<Icons.ExternalLink className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("newTab")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mutate({ id: chat.id });
|
||||
}}
|
||||
>
|
||||
<Icons.Trash className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("delete")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
78
apps/web/src/modules/chat/layout/examples.tsx
Normal file
78
apps/web/src/modules/chat/layout/examples.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } 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 { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
|
||||
const examples = [
|
||||
{
|
||||
icon: Icons.FileText,
|
||||
label: "chat.example.summarize.label",
|
||||
prompt: "chat.example.summarize.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.ChartNoAxesColumn,
|
||||
label: "chat.example.analyze.label",
|
||||
prompt: "chat.example.analyze.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Code,
|
||||
label: "chat.example.code.label",
|
||||
prompt: "chat.example.code.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Zap,
|
||||
label: "chat.example.brainstorm.label",
|
||||
prompt: "chat.example.brainstorm.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.PackageOpen,
|
||||
label: "chat.example.surprise.label",
|
||||
prompt: "chat.example.surprise.prompt",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface ExamplesProps {
|
||||
readonly id?: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export const Examples = memo<ExamplesProps>(({ className, id }) => {
|
||||
const { t } = useTranslation("ai");
|
||||
const { onSubmit } = useComposer({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row flex-wrap items-center justify-center gap-2 px-3 @sm:gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{examples.map(({ icon: Icon, label, prompt }, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
initial={{ opacity: 0, y: 3, filter: "blur(4px)" }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
key={label}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-2 rounded-full"
|
||||
onClick={() => onSubmit(t(prompt))}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{t(label)}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Examples.displayName = "Examples";
|
||||
14
apps/web/src/modules/chat/layout/headline.tsx
Normal file
14
apps/web/src/modules/chat/layout/headline.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { getGreeting } from "@turbostarter/shared/utils";
|
||||
|
||||
export const Headline = () => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { text, emoji } = getGreeting();
|
||||
|
||||
return (
|
||||
<h1 className="leading-tighter flex w-full flex-col items-center justify-center text-center text-2xl tracking-tight @sm:text-3xl @md:text-4xl">
|
||||
{t(`greeting.${text}`)} {emoji}
|
||||
<span className="text-muted-foreground">{t("ai:chat.headline")}</span>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
32
apps/web/src/modules/chat/layout/new.tsx
Normal file
32
apps/web/src/modules/chat/layout/new.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
import { Examples } from "~/modules/chat/layout/examples";
|
||||
import { Headline } from "~/modules/chat/layout/headline";
|
||||
|
||||
interface NewChatProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const NewChat = memo<NewChatProps>(({ id }) => {
|
||||
const { model } = useComposer({ id });
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-between gap-6 md:justify-center md:gap-9 md:p-2">
|
||||
<div className="flex w-full grow items-end">
|
||||
<Headline />
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col items-center justify-between md:flex-col-reverse md:justify-end md:gap-5">
|
||||
<Examples className="flex" id={id} />
|
||||
<div className="relative w-full px-3 pb-3">
|
||||
<ChatComposer id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
NewChat.displayName = "NewChat";
|
||||
34
apps/web/src/modules/chat/layout/view.tsx
Normal file
34
apps/web/src/modules/chat/layout/view.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { Chat } from "~/modules/chat/thread";
|
||||
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ViewChatProps {
|
||||
readonly id: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ViewChat = memo<ViewChatProps>(({ id, initialMessages }) => {
|
||||
const { model } = useComposer({ id, initialMessages });
|
||||
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<Chat id={id} initialMessages={initialMessages} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-[50rem]">
|
||||
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
|
||||
<ChatComposer id={id} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
ViewChat.displayName = "ViewChat";
|
||||
38
apps/web/src/modules/chat/lib/api.ts
Normal file
38
apps/web/src/modules/chat/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { chatSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const KEY = "chat";
|
||||
|
||||
const queries = {
|
||||
chats: {
|
||||
user: {
|
||||
getAll: (userId: string) => ({
|
||||
queryKey: [KEY, "chats", userId],
|
||||
queryFn: handle(api.ai.chat.chats.$get, {
|
||||
schema: z.array(chatSchema),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
chats: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "chats", "delete"],
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
handle(api.ai.chat.chats[":id"].$delete)({
|
||||
param: { id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chat = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
39
apps/web/src/modules/chat/thread/index.tsx
Normal file
39
apps/web/src/modules/chat/thread/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import { Thread } from "../../common/ai/thread";
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import { AssistantMessage } from "./message/assistant";
|
||||
import { UserMessage } from "./message/user";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
const components = {
|
||||
[Role.USER]: UserMessage,
|
||||
[Role.ASSISTANT]: AssistantMessage,
|
||||
};
|
||||
|
||||
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
|
||||
const { messages, regenerate, error, status } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
<Thread
|
||||
messages={messages}
|
||||
initialMessages={initialMessages}
|
||||
status={status}
|
||||
components={components}
|
||||
error={error}
|
||||
regenerate={regenerate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/modules/chat/thread/message/assistant/index.tsx
Normal file
67
apps/web/src/modules/chat/thread/message/assistant/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { WebSearch } from "~/modules/chat/thread/message/assistant/tools/web-search";
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import { ReasoningMessagePart } from "./reasoning";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
export const AssistantMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref, status }) => {
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-start" ref={ref}>
|
||||
<Prose className="w-full max-w-none">
|
||||
{message.parts.map((part, partIndex) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MemoizedMarkdown
|
||||
key={`${message.id}-${partIndex}`}
|
||||
content={part.text}
|
||||
id={`text-${partIndex}`}
|
||||
/>
|
||||
);
|
||||
case "reasoning":
|
||||
return (
|
||||
<ReasoningMessagePart
|
||||
key={`${message.id}-${partIndex}`}
|
||||
part={part}
|
||||
reasoning={
|
||||
status === "streaming" &&
|
||||
partIndex === message.parts.length - 1
|
||||
}
|
||||
defaultExpanded={status === "streaming"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tool-web-search":
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
case "output-available":
|
||||
return (
|
||||
<WebSearch
|
||||
key={`${message.id}-${partIndex}`}
|
||||
{...part}
|
||||
annotations={message.parts.filter(
|
||||
(p) => p.type === "data-query_completion",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Prose>
|
||||
|
||||
{!["submitted", "streaming"].includes(status) && (
|
||||
<ThreadMessage.Controls message={message} />
|
||||
)}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AssistantMessage.displayName = "AssistantMessage";
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
|
||||
import type { ReasoningUIPart } from "ai";
|
||||
|
||||
interface ReasoningMessagePartProps {
|
||||
part: ReasoningUIPart;
|
||||
reasoning: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ReasoningMessagePart({
|
||||
part,
|
||||
reasoning,
|
||||
defaultExpanded = false,
|
||||
}: ReasoningMessagePartProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!part.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultExpanded ? "reasoning" : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="reasoning" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"not-prose border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1 md:p-1.5">
|
||||
{reasoning ? (
|
||||
<Icons.Loader className="text-muted-foreground size-3.5 animate-spin md:size-4" />
|
||||
) : (
|
||||
<Icons.Sparkle className="text-muted-foreground size-3.5 md:size-4" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{reasoning
|
||||
? t("reasoning.inProgress")
|
||||
: t("reasoning.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="rounded-b-xl border border-t-0 px-5 py-3 shadow-xs">
|
||||
<div className="text-muted-foreground prose-p:my-1.5 text-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MemoizedMarkdown id={part.type} content={part.text} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { SearchResult } from ".";
|
||||
|
||||
type SearchImage = SearchResult["images"][number];
|
||||
|
||||
export const PREVIEW_IMAGE_COUNT = {
|
||||
MOBILE: 4,
|
||||
DESKTOP: 5,
|
||||
};
|
||||
|
||||
interface ImageGridProps {
|
||||
images: SearchImage[];
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const ImageThumbnail = ({
|
||||
image,
|
||||
index,
|
||||
onClick,
|
||||
isLast,
|
||||
hasMore,
|
||||
moreCount,
|
||||
}: {
|
||||
image: SearchImage;
|
||||
index: number;
|
||||
onClick: () => void;
|
||||
isLast: boolean;
|
||||
hasMore: boolean;
|
||||
moreCount: number;
|
||||
}) => (
|
||||
<Thumbnail onClick={onClick} index={index}>
|
||||
<ThumbnailImage src={image.url} alt={image.description} />
|
||||
{image.description && (!isLast || !hasMore) && (
|
||||
<div className="absolute inset-0 flex items-end bg-black/60 px-3 py-4 opacity-0 transition-opacity duration-200 group-hover/thumbnail:opacity-100">
|
||||
<p className="line-clamp-3 text-xs text-white">{image.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{isLast && hasMore && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-sm font-medium text-white">+{moreCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
export const ImageGrid = ({ images, showAll = false }: ImageGridProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const isDesktop = useBreakpoint("md");
|
||||
|
||||
const displayImages = showAll
|
||||
? images
|
||||
: images.slice(
|
||||
0,
|
||||
isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
);
|
||||
const hasMore =
|
||||
images.length >
|
||||
(isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2",
|
||||
displayImages.length === 1 && "grid-cols-1",
|
||||
"sm:grid-cols-3",
|
||||
"lg:grid-cols-4",
|
||||
"*:aspect-4/3",
|
||||
"[&>*:first-child]:col-span-1 [&>*:first-child]:row-span-1",
|
||||
isDesktop &&
|
||||
displayImages.length > 1 &&
|
||||
"[&>*:first-child]:col-span-2 [&>*:first-child]:row-span-2",
|
||||
displayImages.length === 1 &&
|
||||
"grid-cols-1! [&>*:first-child]:col-span-1! [&>*:first-child]:row-span-2!",
|
||||
)}
|
||||
>
|
||||
{displayImages.map((image, index) => (
|
||||
<ImageThumbnail
|
||||
key={index}
|
||||
image={image}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
isLast={index === displayImages.length - 1}
|
||||
hasMore={!showAll && hasMore}
|
||||
moreCount={images.length - displayImages.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={images}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { ImageGrid } from "./images";
|
||||
import { SearchLoading } from "./loading";
|
||||
|
||||
import type {
|
||||
ChatDataParts,
|
||||
ChatTools,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
const ResultCard = ({
|
||||
result,
|
||||
}: {
|
||||
result: SearchResult["results"][number];
|
||||
}) => {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background h-full w-[300px] shrink-0 rounded-xl border shadow-xs">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="bg-muted relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{!imageLoaded && (
|
||||
<div className="bg-muted-foreground/10 absolute inset-0 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
||||
alt=""
|
||||
className={cn("size-8 object-cover", !imageLoaded && "opacity-0")}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={(e) => {
|
||||
setImageLoaded(true);
|
||||
e.currentTarget.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='16'/%3E%3Cline x1='8' y1='12' x2='16' y2='12'/%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="line-clamp-1 text-sm font-medium">{result.title}</h3>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
|
||||
>
|
||||
{new URL(result.url).hostname}
|
||||
<Icons.ExternalLink className="size-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground line-clamp-3 text-sm">
|
||||
{result.content}
|
||||
</p>
|
||||
|
||||
{result.publishedDate && (
|
||||
<div className="pt-2">
|
||||
<time className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icons.Calendar className="size-3" />
|
||||
{new Date(result.publishedDate).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SearchResult = NonNullable<
|
||||
ChatTools[typeof Tool.WEB_SEARCH]["output"]
|
||||
>["searches"][number];
|
||||
|
||||
export const WebSearch = (
|
||||
props: Partial<ChatTools[typeof Tool.WEB_SEARCH]> & {
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
},
|
||||
) => {
|
||||
const { input, output, annotations } = props;
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!output) {
|
||||
return (
|
||||
<SearchLoading queries={input?.queries ?? []} annotations={annotations} />
|
||||
);
|
||||
}
|
||||
|
||||
const allImages = output.searches.reduce<SearchResult["images"]>(
|
||||
(acc, search) => {
|
||||
return [...acc, ...search.images];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const totalResults = output.searches.reduce(
|
||||
(sum, search) => sum + search.results.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"[&[data-state=open]]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Globe className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{output.searches.map((search, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className="bg-muted flex-shrink-0 rounded-full px-3 py-1.5"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{search.query.q}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto">
|
||||
{output.searches.map((search) =>
|
||||
search.results.map((result, resultIndex) => (
|
||||
<motion.div
|
||||
key={`${search.query.q}-${resultIndex}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: resultIndex * 0.1 }}
|
||||
>
|
||||
<ResultCard result={result} />
|
||||
</motion.div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{allImages.length > 0 && <ImageGrid images={allImages} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { PREVIEW_IMAGE_COUNT } from "./images";
|
||||
|
||||
import type {
|
||||
ChatTools,
|
||||
ChatDataParts,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
export const SearchLoading = ({
|
||||
queries,
|
||||
annotations,
|
||||
}: {
|
||||
queries: ChatTools[typeof Tool.WEB_SEARCH]["input"]["queries"];
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
}) => {
|
||||
const isDesktop = useBreakpoint("md");
|
||||
const { t } = useTranslation("common");
|
||||
const totalResults = annotations.reduce(
|
||||
(sum, a) => sum + a.data.resultsCount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Loader className="text-muted-foreground size-4 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.inProgress")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults || "0"} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{queries.map((query, i) => {
|
||||
const annotation = annotations.find(
|
||||
(a) =>
|
||||
a.data.query.q === query.q &&
|
||||
a.data.status === "completed",
|
||||
);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"shrink-0 gap-1.5 rounded-full px-3 py-1.5",
|
||||
!annotation && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{annotation ? (
|
||||
<Icons.Check className="size-3" />
|
||||
) : (
|
||||
<Icons.Loader2 className="size-3 animate-spin stroke-[3px]" />
|
||||
)}
|
||||
{query.q}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto pb-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border bg-background w-[300px] shrink-0 rounded-xl border shadow-xs"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({
|
||||
length: isDesktop
|
||||
? PREVIEW_IMAGE_COUNT.DESKTOP
|
||||
: PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
}).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn(
|
||||
"aspect-4/3 rounded-xl",
|
||||
i === 0 && "sm:col-span-2 sm:row-span-2",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
apps/web/src/modules/chat/thread/message/user/index.tsx
Normal file
85
apps/web/src/modules/chat/thread/message/user/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { FileUIPart } from "ai";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
const Attachments = ({ attachments }: { attachments: FileUIPart[] }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex max-w-full flex-row flex-wrap items-center justify-end gap-1.5">
|
||||
{attachments
|
||||
.filter((attachment) => attachment.mediaType.includes("image/"))
|
||||
.map((attachment, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={attachment.url}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedImage(index);
|
||||
}}
|
||||
className="aspect-square h-24 w-24 border bg-transparent shadow-none sm:h-32 sm:w-32 dark:bg-transparent"
|
||||
>
|
||||
<ThumbnailImage
|
||||
src={attachment.url}
|
||||
alt=""
|
||||
key={attachment.url}
|
||||
/>
|
||||
</Thumbnail>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref }) => {
|
||||
const attachments = message.parts.filter((part) => part.type === "file");
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-end" ref={ref}>
|
||||
{attachments.length > 0 && (
|
||||
<Attachments
|
||||
key={`${message.id}-attachments`}
|
||||
attachments={attachments}
|
||||
/>
|
||||
)}
|
||||
{message.parts.map((part, index) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Prose
|
||||
key={`${message.id}-${index}`}
|
||||
className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]"
|
||||
>
|
||||
{part.text}
|
||||
</Prose>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserMessage.displayName = "UserMessage";
|
||||
Reference in New Issue
Block a user