Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions app/(main)/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
ChatEmptyState,
ChatInput,
ChatMessageList,
VoiceInput,
} from "@/app/components/chat";
import VoiceInput from "@/app/components/chat/VoiceInput";
import { useConfigs } from "@/app/hooks";
import { useVoiceChat } from "@/app/hooks/useVoiceChat";
import {
Expand All @@ -33,6 +33,7 @@
ChatMessage,
LLMCallRequest,
LLMInput,
LLMStructuredInput,
SendInput,
} from "@/app/lib/types/chat";
import { SavedConfig } from "@/app/lib/types/configs";
Expand All @@ -45,7 +46,7 @@
}

function buildUserMessage(input: SendInput): ChatMessage {
return {
const base: ChatMessage = {
id: genId(),
role: "user",
content:
Expand All @@ -56,18 +57,49 @@
status: "complete",
isVoice: input.kind === "audio",
};
if (input.kind === "text" && input.attachments?.length) {
base.attachments = input.attachments.map((a) => ({
kind: a.kind,
name: a.name,
mimeType: a.mimeType,
previewUrl: `data:${a.mimeType};base64,${a.base64}`,
}));
}
return base;
}

function buildLLMInput(input: SendInput): LLMInput {
if (input.kind === "text") return input.text.trim();
return {
type: "audio",
content: {
format: "base64",
value: input.base64,
mime_type: input.mimeType,
},
};
if (input.kind === "audio") {
return {
type: "audio",
content: {
format: "base64",
value: input.base64,
mime_type: input.mimeType,
},
};
}
const text = input.text.trim();
const files = input.attachments ?? [];
if (files.length === 0) return text;
const items: LLMStructuredInput[] = [];
if (text) {
items.push({
type: "text",
content: { format: "text", value: text },
});
}
for (const a of files) {
items.push({
type: a.kind,
content: {
format: "base64",
value: a.base64,
mime_type: a.mimeType,
},
});
}
return items;
}

function buildPayload(
Expand Down Expand Up @@ -144,7 +176,7 @@
return null;
}

export default function ChatPage() {

Check warning on line 179 in app/(main)/chat/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'ChatPage' has a complexity of 22. Maximum allowed is 10

Check warning on line 179 in app/(main)/chat/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'ChatPage' has too many statements (36). Maximum allowed is 20
const { sidebarCollapsed } = useApp();
const { isAuthenticated, activeKey, isHydrated } = useAuth();
const apiKey = activeKey?.key ?? "";
Expand Down Expand Up @@ -180,6 +212,11 @@
return () => abortRef.current?.abort();
}, []);

useEffect(() => {
if (!configId || !configVersion) return;
void loadSingleVersion(configId, configVersion);
}, [configId, configVersion, loadSingleVersion]);

const handleNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
Expand All @@ -200,8 +237,14 @@
);

const sendMessage = useCallback(
async (input: SendInput): Promise<string | null> => {

Check warning on line 240 in app/(main)/chat/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Async arrow function has a complexity of 28. Maximum allowed is 10

Check warning on line 240 in app/(main)/chat/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Async arrow function has too many statements (48). Maximum allowed is 20
if (input.kind === "text" && !input.text.trim()) return null;
if (
input.kind === "text" &&
!input.text.trim() &&
!input.attachments?.length
) {
return null;
}

if (!isAuthenticated) {
setShowLoginModal(true);
Expand Down Expand Up @@ -357,7 +400,7 @@
const textConfigReady =
!activeConfig || (activeConfig.type?.toLowerCase() ?? "text") !== "stt";

const handleStartVoice = useCallback(async () => {

Check warning on line 403 in app/(main)/chat/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Async arrow function has too many statements (21). Maximum allowed is 20
if (!isAuthenticated) {
setShowLoginModal(true);
return;
Expand Down Expand Up @@ -459,7 +502,10 @@
<ChatInput
value={draft}
onChange={setDraft}
onSend={() => sendMessage({ kind: "text", text: draft })}
onSend={(attachments) =>
sendMessage({ kind: "text", text: draft, attachments })
}
onAttachmentError={(msg) => toast.error(msg)}
isPending={isPending}
onStartVoice={handleStartVoice}
voiceConfigReady={hasConfig ? voiceConfigReady : undefined}
Expand Down
88 changes: 88 additions & 0 deletions app/components/chat/AttachmentChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { MouseEvent } from "react";
import { CloseIcon, DocumentFileIcon } from "@/app/components/icons";

export interface AttachmentChipFile {
id: string;
kind: "image" | "pdf";
name: string;
mimeType: string;
previewUrl: string;
}

interface AttachmentChipProps {
file: AttachmentChipFile;
onRemove: () => void;
onPreview: () => void;
}

export default function AttachmentChip({
file,
onRemove,
onPreview,
}: AttachmentChipProps) {
const handleRemove = (e: MouseEvent) => {
e.stopPropagation();
onRemove();
};
if (file.kind === "image") {
return (
<div className="relative group">
<button
type="button"
onClick={onPreview}
aria-label={`Preview ${file.name}`}
title={`Preview ${file.name}`}
className="block rounded-lg overflow-hidden cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-primary"
>
<img
src={file.previewUrl}
alt={file.name}
className="w-16 h-16 rounded-lg object-cover bg-bg-primary shadow-[0_2px_6px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.05)]"
/>
</button>
<button
type="button"
onClick={handleRemove}
aria-label={`Remove ${file.name}`}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-text-primary text-white flex items-center justify-center shadow-[0_1px_3px_rgba(0,0,0,0.2)] hover:bg-neutral-800 transition-colors cursor-pointer"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
);
}
return (
<div className="relative">
<button
type="button"
onClick={onPreview}
aria-label={`Preview ${file.name}`}
title={`Preview ${file.name}`}
className="inline-flex items-center gap-2 pr-7 pl-2 py-1.5 rounded-lg bg-bg-primary shadow-[0_2px_6px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.05)] hover:bg-bg-secondary transition-colors cursor-pointer text-left"
>
<span className="inline-flex items-center justify-center w-8 h-8 rounded-md bg-accent-primary text-white shrink-0">
<DocumentFileIcon className="w-4 h-4" />
</span>
<span className="flex flex-col min-w-0 max-w-[180px]">
<span
className="text-sm font-medium text-text-primary truncate"
title={file.name}
>
{file.name}
</span>
<span className="text-[11px] text-text-secondary">PDF</span>
</span>
</button>
<button
type="button"
onClick={handleRemove}
aria-label={`Remove ${file.name}`}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-text-primary text-white flex items-center justify-center shadow-[0_1px_3px_rgba(0,0,0,0.2)] hover:bg-neutral-800 transition-colors cursor-pointer"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
);
}
53 changes: 53 additions & 0 deletions app/components/chat/AttachmentPreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { Modal } from "@/app/components/ui";

export interface PreviewableAttachment {
kind: "image" | "pdf";
name: string;
mimeType: string;
previewUrl?: string;
}

interface AttachmentPreviewModalProps {
attachment: PreviewableAttachment | null;
onClose: () => void;
}

export default function AttachmentPreviewModal({
attachment,
onClose,
}: AttachmentPreviewModalProps) {
const open = !!attachment;
return (
<Modal
open={open}
onClose={onClose}
title={attachment?.name ?? "Preview"}
maxWidth="max-w-4xl"
maxHeight="max-h-[90vh]"
>
<div className="px-6 pb-6 h-full">
{attachment?.kind === "image" && attachment.previewUrl ? (
<div className="flex items-center justify-center h-full">
<img
src={attachment.previewUrl}
alt={attachment.name}
className="max-w-full max-h-[75vh] object-contain rounded-lg"
/>
</div>
) : attachment?.previewUrl ? (
<iframe
src={attachment.previewUrl}
title={attachment.name}
className="w-full h-[75vh] rounded-lg border border-border"
/>
) : (
<p className="text-sm text-text-secondary text-center py-12">
Preview is not available for this attachment.
</p>
)}
</div>
</Modal>
);
}
Loading
Loading