barreloflube commited on
Commit
12621bc
·
1 Parent(s): 4a3300f

refactor: improve chat page and manager with new features and optimizations

Browse files
src/components/sidebar/app-sidebar.tsx CHANGED
@@ -23,7 +23,8 @@ export function AppSidebar() {
23
  const location = useLocation();
24
  const navigate = useNavigate();
25
  const chats = useLiveQuery(async () => {
26
- return await new ChatHistoryDB().sessions.toArray();
 
27
  });
28
  const [hoveringId, setHoveringId] = useState<string | null>(null);
29
 
@@ -82,10 +83,11 @@ export function AppSidebar() {
82
  ? 'translate-x-0'
83
  : 'translate-x-[200%]'
84
  }`}
85
- onClick={() => {
 
86
  new ChatHistoryDB().sessions.delete(chat.id);
87
  toast.success("Chat deleted");
88
- return navigate("/chat/new", { replace: true });
89
  }}
90
  >
91
  <Trash2 className="h-4 w-4" />
 
23
  const location = useLocation();
24
  const navigate = useNavigate();
25
  const chats = useLiveQuery(async () => {
26
+ const sessions = await new ChatHistoryDB().sessions.toArray();
27
+ return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
28
  });
29
  const [hoveringId, setHoveringId] = useState<string | null>(null);
30
 
 
83
  ? 'translate-x-0'
84
  : 'translate-x-[200%]'
85
  }`}
86
+ onClick={(e) => {
87
+ e.preventDefault();
88
  new ChatHistoryDB().sessions.delete(chat.id);
89
  toast.success("Chat deleted");
90
+ return navigate("/chat/new");
91
  }}
92
  >
93
  <Trash2 className="h-4 w-4" />
src/lib/chat/manager.ts CHANGED
@@ -364,7 +364,7 @@ export class ChatManager {
364
  yield { type: "stream", content: chunk };
365
  }
366
  } else if (event.event === "on_chat_model_end") {
367
- yield { type: "end", content: currentResponse };
368
  } else if (event.event === "on_tool_start") {
369
  yield { type: "tool_start", name: event.name, input: event.data?.input };
370
  } else if (event.event === "on_tool_end") {
@@ -385,4 +385,16 @@ export class ChatManager {
385
  }
386
  }
387
  }
 
 
 
 
 
 
 
 
 
 
 
 
388
  }
 
364
  yield { type: "stream", content: chunk };
365
  }
366
  } else if (event.event === "on_chat_model_end") {
367
+ yield { type: "end", content: currentResponse, usageMetadata: event.data?.output?.usage_metadata };
368
  } else if (event.event === "on_tool_start") {
369
  yield { type: "tool_start", name: event.name, input: event.data?.input };
370
  } else if (event.event === "on_tool_end") {
 
385
  }
386
  }
387
  }
388
+
389
+ async chatChain(
390
+ input: string | HumanMessage,
391
+ systemPrompt?: string,
392
+ ) {
393
+ const model = await this.getChatModel(this.config.default_chat_model);
394
+ const humanMessage = typeof input === "string" ? new HumanMessage(input) : input;
395
+ return await model.invoke([
396
+ { type: "system", content: systemPrompt || "You are a helpful assistant" },
397
+ humanMessage
398
+ ]);
399
+ }
400
  }
src/pages/chat/components/AIMessage.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { ClipboardCopy } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import { MessageProps } from "../types";
6
+ import ReactMarkdown from "react-markdown";
7
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
8
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
9
+ import remarkMath from "remark-math";
10
+ import rehypeKatex from "rehype-katex";
11
+ import remarkGfm from "remark-gfm";
12
+ import { Badge } from "@/components/ui/badge";
13
+ import "katex/dist/katex.min.css";
14
+
15
+ export const AIMessageComponent = React.memo(({ message }: MessageProps) => {
16
+ const handleCopy = React.useCallback(() => {
17
+ const content = String(message.content);
18
+ navigator.clipboard.writeText(content)
19
+ .then(() => toast.success("Response copied to clipboard"))
20
+ .catch(() => toast.error("Failed to copy response"));
21
+ }, [message.content]);
22
+
23
+ return (
24
+ <div className="flex flex-col gap-4 group">
25
+ <div className="prose prose-sm dark:prose-invert max-w-none">
26
+ <ReactMarkdown
27
+ remarkPlugins={[remarkMath, remarkGfm]}
28
+ rehypePlugins={[rehypeKatex]}
29
+ components={{
30
+ p(props) {
31
+ return <p {...props} className="leading-7 mb-4" />;
32
+ },
33
+ h1(props) {
34
+ return <h1 {...props} className="text-3xl font-bold tracking-tight mb-4 mt-8" />;
35
+ },
36
+ h2(props) {
37
+ return <h2 {...props} className="text-2xl font-semibold tracking-tight mb-4 mt-8" />;
38
+ },
39
+ h3(props) {
40
+ return <h3 {...props} className="text-xl font-semibold tracking-tight mb-4 mt-6" />;
41
+ },
42
+ h4(props) {
43
+ return <h4 {...props} className="text-lg font-semibold tracking-tight mb-4 mt-6" />;
44
+ },
45
+ code(props) {
46
+ const {children, className, ...rest} = props;
47
+ const match = /language-(\w+)/.exec(className || '');
48
+ const language = match ? match[1] : '';
49
+ const code = String(children).replace(/\n$/, '');
50
+
51
+ const copyToClipboard = () => {
52
+ navigator.clipboard.writeText(code);
53
+ toast.success("Code copied to clipboard");
54
+ };
55
+
56
+ return match ? (
57
+ <div className="relative rounded-md overflow-hidden my-6">
58
+ <div className="absolute right-2 top-2 flex items-center gap-2">
59
+ {language && (
60
+ <Badge variant="secondary" className="text-xs font-mono">
61
+ {language}
62
+ </Badge>
63
+ )}
64
+ <Button
65
+ variant="ghost"
66
+ size="icon"
67
+ className="h-6 w-6 bg-muted/50 hover:bg-muted"
68
+ onClick={copyToClipboard}
69
+ >
70
+ <ClipboardCopy className="h-3 w-3" />
71
+ </Button>
72
+ </div>
73
+ <SyntaxHighlighter
74
+ style={oneDark}
75
+ language={language}
76
+ PreTag="div"
77
+ customStyle={{ margin: 0, borderRadius: 0, padding: "1.5rem" }}
78
+ >
79
+ {code}
80
+ </SyntaxHighlighter>
81
+ </div>
82
+ ) : (
83
+ <code {...rest} className={`${className} bg-muted px-1.5 py-0.5 rounded-md text-sm`}>
84
+ {children}
85
+ </code>
86
+ );
87
+ },
88
+ a(props) {
89
+ return <a {...props} className="text-primary hover:underline font-medium" target="_blank" rel="noopener noreferrer" />;
90
+ },
91
+ table(props) {
92
+ return <div className="my-6 w-full overflow-y-auto"><table {...props} className="w-full border-collapse table-auto" /></div>;
93
+ },
94
+ th(props) {
95
+ return <th {...props} className="border border-muted-foreground px-4 py-2 text-left font-semibold" />;
96
+ },
97
+ td(props) {
98
+ return <td {...props} className="border border-muted-foreground px-4 py-2" />;
99
+ },
100
+ blockquote(props) {
101
+ return <blockquote {...props} className="mt-6 border-l-4 border-primary pl-6 italic" />;
102
+ },
103
+ ul(props) {
104
+ return <ul {...props} className="my-6 ml-6 list-disc [&>li]:mt-2" />;
105
+ },
106
+ ol(props) {
107
+ return <ol {...props} className="my-6 ml-6 list-decimal [&>li]:mt-2" />;
108
+ },
109
+ li(props) {
110
+ return <li {...props} className="leading-7" />;
111
+ },
112
+ hr(props) {
113
+ return <hr {...props} className="my-6 border-muted" />;
114
+ }
115
+ }}
116
+ >
117
+ {String(message.content)}
118
+ </ReactMarkdown>
119
+ </div>
120
+ <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
121
+ <Button variant="ghost" size="sm" onClick={handleCopy}>
122
+ <ClipboardCopy className="h-4 w-4 mr-2" />
123
+ Copy response
124
+ </Button>
125
+ </div>
126
+ </div>
127
+ );
128
+ });
129
+
130
+ AIMessageComponent.displayName = "AIMessageComponent";
src/pages/chat/components/AttachmentDropdown.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
4
+ import { Input as InputField } from "@/components/ui/input";
5
+ import { Paperclip, Upload, Link2 } from "lucide-react";
6
+
7
+ interface AttachmentDropdownProps {
8
+ isUrlInputOpen: boolean;
9
+ setIsUrlInputOpen: (open: boolean) => void;
10
+ urlInput: string;
11
+ setUrlInput: (url: string) => void;
12
+ handleAttachmentFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
13
+ handleAttachmentUrlUpload: () => void;
14
+ }
15
+
16
+ export const AttachmentDropdown = React.memo(({
17
+ isUrlInputOpen,
18
+ setIsUrlInputOpen,
19
+ urlInput,
20
+ setUrlInput,
21
+ handleAttachmentFileUpload,
22
+ handleAttachmentUrlUpload
23
+ }: AttachmentDropdownProps) => (
24
+ <DropdownMenu>
25
+ <DropdownMenuTrigger asChild>
26
+ <Button
27
+ variant="ghost"
28
+ size="icon"
29
+ className="h-8 w-8"
30
+ >
31
+ <Paperclip className="h-4 w-4" />
32
+ </Button>
33
+ </DropdownMenuTrigger>
34
+ <DropdownMenuContent align="start" className="w-[300px]">
35
+ <DropdownMenuItem
36
+ onSelect={() => {
37
+ const input = document.createElement("input");
38
+ input.type = "file";
39
+ input.multiple = true;
40
+ input.accept = ".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.gif,.mp3,.mp4,.wav,.ogg";
41
+ input.onchange = (e) => handleAttachmentFileUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
42
+ input.click();
43
+ }}
44
+ >
45
+ <Upload className="h-4 w-4 mr-2" />
46
+ Upload Files
47
+ </DropdownMenuItem>
48
+ <DropdownMenuSeparator />
49
+ <DropdownMenuItem onSelect={(e) => {
50
+ e.preventDefault();
51
+ setIsUrlInputOpen(true);
52
+ }}>
53
+ <Link2 className="h-4 w-4 mr-2" />
54
+ Add URLs
55
+ </DropdownMenuItem>
56
+ {isUrlInputOpen && (
57
+ <div className="p-2 flex gap-2">
58
+ <InputField
59
+ value={urlInput}
60
+ onChange={(e) => setUrlInput(e.target.value)}
61
+ placeholder="Enter URLs (comma-separated)"
62
+ className="flex-1"
63
+ />
64
+ <Button size="sm" onClick={handleAttachmentUrlUpload}>
65
+ Add
66
+ </Button>
67
+ </div>
68
+ )}
69
+ </DropdownMenuContent>
70
+ </DropdownMenu>
71
+ ));
72
+
73
+ AttachmentDropdown.displayName = "AttachmentDropdown";
src/pages/chat/components/DocumentBadge.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { Button } from "@/components/ui/button";
4
+ import { X } from "lucide-react";
5
+ import { IDocument } from "@/lib/document/types";
6
+
7
+ interface DocumentBadgeProps {
8
+ document: IDocument;
9
+ onPreview: () => void;
10
+ onRemove: () => void;
11
+ removeable?: boolean;
12
+ }
13
+
14
+ export const DocumentBadge = React.memo(({
15
+ document,
16
+ onPreview,
17
+ onRemove,
18
+ removeable = true
19
+ }: DocumentBadgeProps) => (
20
+ <Badge
21
+ key={document.id}
22
+ className="gap-1 cursor-pointer justify-between max-w-[200px] w-fit truncate bg-muted-foreground/20 hover:bg-primary/5"
23
+ onClick={onPreview}
24
+ >
25
+ <span className="truncate">{document.name}</span>
26
+ {removeable && (
27
+ <Button
28
+ variant="ghost"
29
+ size="icon"
30
+ className="h-4 w-4"
31
+ onClick={(e) => {
32
+ e.stopPropagation();
33
+ onRemove();
34
+ }}
35
+ >
36
+ <X className="h-3 w-3" />
37
+ </Button>
38
+ )}
39
+ </Badge>
40
+ ));
41
+
42
+ DocumentBadge.displayName = "DocumentBadge";
src/pages/chat/components/DocumentBadgesScrollArea.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { ScrollArea } from "@/components/ui/scroll-area";
3
+ import { cn } from "@/lib/utils";
4
+ import { IDocument } from "@/lib/document/types";
5
+ import { DocumentBadge } from "./DocumentBadge";
6
+
7
+ interface DocumentBadgesScrollAreaProps {
8
+ documents: IDocument[];
9
+ onPreview: (doc: IDocument) => void;
10
+ onRemove: (docId: string) => void;
11
+ removeable?: boolean;
12
+ maxHeight?: string;
13
+ className?: string;
14
+ }
15
+
16
+ export const DocumentBadgesScrollArea = React.memo(({
17
+ documents,
18
+ onPreview,
19
+ onRemove,
20
+ removeable = true,
21
+ maxHeight = "100px",
22
+ className = ""
23
+ }: DocumentBadgesScrollAreaProps) => (
24
+ <ScrollArea className={cn("w-full", className)} style={{ maxHeight }}>
25
+ <div className="flex flex-row-reverse flex-wrap-reverse gap-1 p-1">
26
+ {documents.map((document) => (
27
+ <DocumentBadge
28
+ key={document.id}
29
+ document={document}
30
+ onPreview={() => onPreview(document)}
31
+ onRemove={() => onRemove(document.id)}
32
+ removeable={removeable}
33
+ />
34
+ ))}
35
+ </div>
36
+ </ScrollArea>
37
+ ));
38
+
39
+ DocumentBadgesScrollArea.displayName = "DocumentBadgesScrollArea";
src/pages/chat/components/FilePreviewDialog.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Download, Loader2 } from "lucide-react";
5
+ import { DocumentManager } from "@/lib/document/manager";
6
+ import { FilePreviewDialogProps } from "../types";
7
+ import { toast } from "sonner";
8
+
9
+ export const FilePreviewDialog = React.memo(({ document: fileDoc, onClose }: FilePreviewDialogProps) => {
10
+ const [content, setContent] = React.useState<string | null>(null);
11
+ const [isLoading, setIsLoading] = React.useState(true);
12
+ const [error, setError] = React.useState<string | null>(null);
13
+ const documentManager = React.useMemo(() => DocumentManager.getInstance(), []);
14
+
15
+ React.useEffect(() => {
16
+ if (!fileDoc) return;
17
+
18
+ const loadContent = async () => {
19
+ try {
20
+ setIsLoading(true);
21
+ setError(null);
22
+ const file = await documentManager.getDocument(fileDoc.id);
23
+
24
+ if (fileDoc.type === "image") {
25
+ const reader = new FileReader();
26
+ reader.onload = () => setContent(reader.result as string);
27
+ reader.readAsDataURL(file);
28
+ } else if (fileDoc.type === "pdf") {
29
+ const url = URL.createObjectURL(file);
30
+ setContent(url);
31
+ return () => URL.revokeObjectURL(url);
32
+ } else {
33
+ const text = await file.text();
34
+ setContent(text);
35
+ }
36
+ } catch (err) {
37
+ console.error(err);
38
+ setError("Failed to load file content");
39
+ } finally {
40
+ setIsLoading(false);
41
+ }
42
+ };
43
+
44
+ loadContent();
45
+ }, [fileDoc, documentManager]);
46
+
47
+ const handleDownload = React.useCallback(async () => {
48
+ if (!fileDoc) return;
49
+ try {
50
+ const file = await documentManager.getDocument(fileDoc.id);
51
+ const url = URL.createObjectURL(file);
52
+ const a = document.createElement('a');
53
+ a.href = url;
54
+ a.download = fileDoc.name;
55
+ document.body.appendChild(a);
56
+ a.click();
57
+ document.body.removeChild(a);
58
+ URL.revokeObjectURL(url);
59
+ } catch (err) {
60
+ console.error(err);
61
+ toast.error("Failed to download file");
62
+ }
63
+ }, [fileDoc, documentManager]);
64
+
65
+ if (!fileDoc) return null;
66
+
67
+ const renderContent = () => {
68
+ if (isLoading) {
69
+ return (
70
+ <div className="flex items-center justify-center h-full">
71
+ <Loader2 className="h-8 w-8 animate-spin" />
72
+ </div>
73
+ );
74
+ }
75
+ if (error) {
76
+ return (
77
+ <div className="flex items-center justify-center h-full text-destructive">
78
+ {error}
79
+ </div>
80
+ );
81
+ }
82
+ if (content) {
83
+ if (fileDoc.type === "image") {
84
+ return (
85
+ <div className="flex items-center justify-center p-4">
86
+ <img src={content} alt={fileDoc.name} className="max-w-full max-h-full object-contain" />
87
+ </div>
88
+ );
89
+ }
90
+ if (fileDoc.type === "pdf") {
91
+ return <iframe src={content} className="h-full w-full rounded-md bg-muted/50" />;
92
+ }
93
+ return (
94
+ <pre className="p-4 whitespace-pre-wrap font-mono text-sm">
95
+ {content}
96
+ </pre>
97
+ );
98
+ }
99
+ return null;
100
+ };
101
+
102
+ return (
103
+ <Dialog open={!!fileDoc} onOpenChange={onClose}>
104
+ <DialogContent className="max-w-4xl max-h-[80vh] h-full flex flex-col">
105
+ <DialogHeader className="flex flex-row items-center justify-between">
106
+ <div className="space-y-1">
107
+ <DialogTitle>{fileDoc.name}</DialogTitle>
108
+ <DialogDescription>
109
+ Type: {fileDoc.type} • Created: {new Date(fileDoc.createdAt).toLocaleString()}
110
+ </DialogDescription>
111
+ </div>
112
+ <Button onClick={handleDownload} variant="outline" size="sm" className="gap-2">
113
+ <Download className="h-4 w-4" />
114
+ Download
115
+ </Button>
116
+ </DialogHeader>
117
+ {renderContent()}
118
+ </DialogContent>
119
+ </Dialog>
120
+ );
121
+ });
122
+
123
+ FilePreviewDialog.displayName = "FilePreviewDialog";
src/pages/chat/components/HumanMessage.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { X, Check, RefreshCcw, Pencil, ClipboardCopy } from "lucide-react";
4
+ import { AutosizeTextarea } from "@/components/ui/autosize-textarea";
5
+ import { toast } from "sonner";
6
+ import { MessageProps } from "../types";
7
+ import { DocumentBadgesScrollArea } from "./DocumentBadgesScrollArea";
8
+
9
+ interface HumanMessageComponentProps extends MessageProps {
10
+ onEdit?: () => void;
11
+ onRegenerate?: () => void;
12
+ isEditing?: boolean;
13
+ onSave?: (content: string) => void;
14
+ onCancelEdit?: () => void;
15
+ }
16
+
17
+ export const HumanMessageComponent = React.memo(({
18
+ message,
19
+ setPreviewDocument,
20
+ onEdit,
21
+ onRegenerate,
22
+ isEditing,
23
+ onSave,
24
+ onCancelEdit
25
+ }: HumanMessageComponentProps) => {
26
+ const [editedContent, setEditedContent] = React.useState(String(message.content));
27
+
28
+ if (message.response_metadata?.documents?.length) {
29
+ return (
30
+ <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end">
31
+ <DocumentBadgesScrollArea
32
+ documents={message.response_metadata.documents}
33
+ onPreview={(doc) => setPreviewDocument?.(doc)}
34
+ onRemove={() => {}}
35
+ removeable={false}
36
+ maxHeight="200px"
37
+ />
38
+ </div>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end group">
44
+ {isEditing ? (
45
+ <div className="flex flex-col gap-2 w-full">
46
+ <AutosizeTextarea
47
+ value={editedContent}
48
+ onChange={(e) => setEditedContent(e.target.value)}
49
+ className="bg-muted p-5 rounded-md"
50
+ maxHeight={300}
51
+ />
52
+ <div className="flex gap-2 justify-end">
53
+ <Button
54
+ variant="ghost"
55
+ size="sm"
56
+ onClick={onCancelEdit}
57
+ >
58
+ <X className="h-4 w-4 mr-2" />
59
+ Cancel
60
+ </Button>
61
+ <Button
62
+ size="sm"
63
+ onClick={() => onSave?.(editedContent)}
64
+ >
65
+ <Check className="h-4 w-4 mr-2" />
66
+ Save
67
+ </Button>
68
+ </div>
69
+ </div>
70
+ ) : (
71
+ <>
72
+ <div className="flex p-5 bg-muted rounded-md" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
73
+ {String(message.content)}
74
+ </div>
75
+ <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
76
+ <Button variant="ghost" size="icon" onClick={onRegenerate}>
77
+ <RefreshCcw className="h-4 w-4" />
78
+ </Button>
79
+ <Button variant="ghost" size="icon" onClick={onEdit}>
80
+ <Pencil className="h-4 w-4" />
81
+ </Button>
82
+ <Button
83
+ variant="ghost"
84
+ size="icon"
85
+ onClick={() => navigator.clipboard.writeText(String(message.content)).then(() => toast.success("Message copied to clipboard"))}
86
+ >
87
+ <ClipboardCopy className="h-4 w-4" />
88
+ </Button>
89
+ </div>
90
+ </>
91
+ )}
92
+ </div>
93
+ );
94
+ });
95
+
96
+ HumanMessageComponent.displayName = "HumanMessageComponent";
src/pages/chat/components/Input.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Send, Loader2 } from "lucide-react";
4
+ import { AutosizeTextarea } from "@/components/ui/autosize-textarea";
5
+ import { InputProps } from "../types";
6
+ import { ModelSelector } from "./ModelSelector";
7
+ import { AttachmentDropdown } from "./AttachmentDropdown";
8
+ import { DocumentBadgesScrollArea } from "./DocumentBadgesScrollArea";
9
+
10
+ export const Input = React.memo(({
11
+ input,
12
+ selectedModel,
13
+ attachments,
14
+ onInputChange,
15
+ onModelChange,
16
+ onSendMessage,
17
+ enabledChatModels,
18
+ setPreviewDocument,
19
+ isUrlInputOpen,
20
+ setIsUrlInputOpen,
21
+ urlInput,
22
+ setUrlInput,
23
+ handleAttachmentFileUpload,
24
+ handleAttachmentUrlUpload,
25
+ handleAttachmentRemove,
26
+ selectedModelName,
27
+ isGenerating,
28
+ }: InputProps) => {
29
+ return (
30
+ <div className="flex flex-col w-1/2 mx-auto bg-muted rounded-md p-1">
31
+ {attachments.length > 0 && (
32
+ <DocumentBadgesScrollArea
33
+ documents={attachments}
34
+ onPreview={setPreviewDocument}
35
+ onRemove={handleAttachmentRemove}
36
+ maxHeight="100px"
37
+ />
38
+ )}
39
+ <AutosizeTextarea
40
+ value={input}
41
+ onChange={(e) => onInputChange(e.target.value)}
42
+ className="bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 resize-none p-2"
43
+ maxHeight={300}
44
+ minHeight={50}
45
+ onKeyDown={(e) => {
46
+ if (e.key === "Enter" && !e.shiftKey) {
47
+ e.preventDefault();
48
+ onSendMessage();
49
+ }
50
+ }}
51
+ />
52
+ <div className="flex flex-row justify-between items-end">
53
+ <div className="flex gap-0">
54
+ <ModelSelector
55
+ selectedModel={selectedModel}
56
+ selectedModelName={selectedModelName}
57
+ enabledChatModels={enabledChatModels}
58
+ onModelChange={onModelChange}
59
+ />
60
+ <AttachmentDropdown
61
+ isUrlInputOpen={isUrlInputOpen}
62
+ setIsUrlInputOpen={setIsUrlInputOpen}
63
+ urlInput={urlInput}
64
+ setUrlInput={setUrlInput}
65
+ handleAttachmentFileUpload={handleAttachmentFileUpload}
66
+ handleAttachmentUrlUpload={handleAttachmentUrlUpload}
67
+ />
68
+ </div>
69
+ <Button
70
+ variant="default"
71
+ size="icon"
72
+ className="rounded-full"
73
+ onClick={onSendMessage}
74
+ disabled={isGenerating}
75
+ >
76
+ {isGenerating ? (
77
+ <Loader2 className="h-4 w-4 animate-spin" />
78
+ ) : (
79
+ <Send className="h-4 w-4" />
80
+ )}
81
+ </Button>
82
+ </div>
83
+ </div>
84
+ );
85
+ });
86
+
87
+ Input.displayName = "Input";
src/pages/chat/components/Messages.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { ScrollArea } from "@/components/ui/scroll-area";
3
+ import { useParams } from "react-router-dom";
4
+ import { isHumanMessage, isAIMessage, AIMessage } from "@langchain/core/messages";
5
+ import { MessagesProps } from "../types";
6
+ import { HumanMessageComponent } from "./HumanMessage";
7
+ import { AIMessageComponent } from "./AIMessage";
8
+ import { Button } from "@/components/ui/button";
9
+ import { ArrowDown } from "lucide-react";
10
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
11
+
12
+ export const Messages = React.memo(({
13
+ messages,
14
+ streamingHumanMessage,
15
+ streamingAIMessageChunks,
16
+ setPreviewDocument,
17
+ onEditMessage,
18
+ onRegenerateMessage,
19
+ editingMessageIndex,
20
+ onSaveEdit,
21
+ onCancelEdit,
22
+ }: MessagesProps) => {
23
+ const { id } = useParams();
24
+ const viewportRef = React.useRef<HTMLDivElement>(null);
25
+ const [showScrollToBottom, setShowScrollToBottom] = React.useState(false);
26
+
27
+ const handleScroll = React.useCallback((event: Event) => {
28
+ const viewport = event.target as HTMLDivElement;
29
+ const isNotAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > 10;
30
+ setShowScrollToBottom(isNotAtBottom);
31
+ }, []);
32
+
33
+ const scrollToBottom = React.useCallback(() => {
34
+ if (!viewportRef.current) return;
35
+ const viewport = viewportRef.current;
36
+ viewport.scrollTo({
37
+ top: viewport.scrollHeight,
38
+ behavior: 'smooth'
39
+ });
40
+ }, []);
41
+
42
+ React.useEffect(() => {
43
+ const viewport = viewportRef.current;
44
+ if (!viewport) return;
45
+
46
+ viewport.addEventListener('scroll', handleScroll);
47
+ // Initial check for scroll position
48
+ handleScroll({ target: viewport } as unknown as Event);
49
+ return () => viewport.removeEventListener('scroll', handleScroll);
50
+ }, [handleScroll]);
51
+
52
+ if (id === "new" || !messages) {
53
+ return <div className="flex-1 min-h-0"><ScrollArea className="h-full" /></div>;
54
+ }
55
+
56
+ return (
57
+ <div className="flex-1 min-h-0 relative">
58
+ <ScrollAreaPrimitive.Root className="h-full">
59
+ <ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full">
60
+ <div className="flex flex-col w-1/2 mx-auto gap-1 pb-4">
61
+ {messages.map((message, index) => {
62
+ if (isHumanMessage(message)) {
63
+ return (
64
+ <HumanMessageComponent
65
+ key={index}
66
+ message={message}
67
+ setPreviewDocument={setPreviewDocument}
68
+ onEdit={() => onEditMessage(index)}
69
+ onRegenerate={() => onRegenerateMessage(index)}
70
+ isEditing={editingMessageIndex === index}
71
+ onSave={onSaveEdit}
72
+ onCancelEdit={onCancelEdit}
73
+ />
74
+ );
75
+ }
76
+ if (isAIMessage(message)) {
77
+ return <AIMessageComponent key={index} message={message} />;
78
+ }
79
+ return null;
80
+ })}
81
+ {streamingHumanMessage && (
82
+ <HumanMessageComponent
83
+ message={streamingHumanMessage}
84
+ setPreviewDocument={setPreviewDocument}
85
+ />
86
+ )}
87
+ {streamingAIMessageChunks.length > 0 && (
88
+ <AIMessageComponent
89
+ message={new AIMessage(streamingAIMessageChunks.map(chunk => chunk.content).join(""))}
90
+ />
91
+ )}
92
+ </div>
93
+ </ScrollAreaPrimitive.Viewport>
94
+ <ScrollAreaPrimitive.Scrollbar orientation="vertical">
95
+ <ScrollAreaPrimitive.Thumb />
96
+ </ScrollAreaPrimitive.Scrollbar>
97
+ </ScrollAreaPrimitive.Root>
98
+ {showScrollToBottom && (
99
+ <Button
100
+ variant="secondary"
101
+ size="icon"
102
+ className="absolute left-1/2 -translate-x-1/2 bottom-4 rounded-full shadow-md hover:bg-accent z-50 bg-background/80 backdrop-blur-sm"
103
+ onClick={scrollToBottom}
104
+ >
105
+ <ArrowDown className="h-4 w-4" />
106
+ </Button>
107
+ )}
108
+ </div>
109
+ );
110
+ });
111
+
112
+ Messages.displayName = "Messages";
src/pages/chat/components/ModelSelector.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Check, Info } from "lucide-react";
7
+ import { CHAT_MODELS } from "@/lib/config/types";
8
+
9
+ interface ModelSelectorProps {
10
+ selectedModel: string;
11
+ selectedModelName: string;
12
+ enabledChatModels: string[] | undefined;
13
+ onModelChange: (model: string) => void;
14
+ }
15
+
16
+ export const ModelSelector = React.memo(({
17
+ selectedModel,
18
+ selectedModelName,
19
+ enabledChatModels,
20
+ onModelChange
21
+ }: ModelSelectorProps) => (
22
+ <DropdownMenu>
23
+ <DropdownMenuTrigger asChild>
24
+ <Button
25
+ variant="ghost"
26
+ className="w-[200px] h-8 p-1 justify-start font-normal"
27
+ >
28
+ {selectedModelName}
29
+ </Button>
30
+ </DropdownMenuTrigger>
31
+ <DropdownMenuContent className="w-[400px]">
32
+ {CHAT_MODELS
33
+ .filter((model) => enabledChatModels?.includes(model.model))
34
+ .map((model) => (
35
+ <TooltipProvider key={model.model}>
36
+ <Tooltip>
37
+ <TooltipTrigger asChild>
38
+ <DropdownMenuItem
39
+ className="p-4"
40
+ onSelect={() => onModelChange(model.model)}
41
+ >
42
+ <div className="flex items-center gap-2 w-full">
43
+ {model.model === selectedModel && (
44
+ <Check className="h-4 w-4 shrink-0" />
45
+ )}
46
+ <span className="flex-grow">{model.name}</span>
47
+ <Info className="h-4 w-4 text-muted-foreground shrink-0" />
48
+ </div>
49
+ </DropdownMenuItem>
50
+ </TooltipTrigger>
51
+ <TooltipContent side="right" align="start" className="max-w-[300px]">
52
+ <p>{model.description}</p>
53
+ {model.modalities && model.modalities.length > 0 && (
54
+ <div className="flex gap-1 flex-wrap mt-1">
55
+ {model.modalities.map((modality) => (
56
+ <Badge key={modality} variant="secondary" className="capitalize text-xs">
57
+ {modality.toLowerCase()}
58
+ </Badge>
59
+ ))}
60
+ </div>
61
+ )}
62
+ </TooltipContent>
63
+ </Tooltip>
64
+ </TooltipProvider>
65
+ ))}
66
+ </DropdownMenuContent>
67
+ </DropdownMenu>
68
+ ));
69
+
70
+ ModelSelector.displayName = "ModelSelector";
src/pages/chat/hooks.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useLiveQuery } from "dexie-react-hooks";
3
+ import { ChatHistoryDB } from "@/lib/chat/memory";
4
+ import { Config, ChatSession } from "./types";
5
+ import { HumanMessage, AIMessageChunk } from "@langchain/core/messages";
6
+ import { ChatManager } from "@/lib/chat/manager";
7
+ import { IDocument } from "@/lib/document/types";
8
+ import { toast } from "sonner";
9
+
10
+ export const useChatSession = (id: string | undefined) => {
11
+ const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []);
12
+ return useLiveQuery(async () => {
13
+ if (!id || id === "new") return null;
14
+ return await chatHistoryDB.sessions.get(id);
15
+ }, [id]);
16
+ };
17
+
18
+ export const useSelectedModel = (id: string | undefined, config: Config | undefined) => {
19
+ const [selectedModel, setSelectedModel] = React.useState<string | null>(null);
20
+ const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []);
21
+
22
+ useLiveQuery(async () => {
23
+ if (!config) return;
24
+
25
+ const model = !id || id === "new"
26
+ ? config.default_chat_model
27
+ : (await chatHistoryDB.sessions.get(id) as ChatSession | undefined)?.model ?? config.default_chat_model;
28
+ setSelectedModel(model);
29
+ }, [id, config]);
30
+
31
+ return [selectedModel, setSelectedModel, chatHistoryDB] as const;
32
+ };
33
+
34
+ export const generateMessage = async (
35
+ chatId: string | undefined,
36
+ input: string,
37
+ attachments: IDocument[],
38
+ isGenerating: boolean,
39
+ setIsGenerating: (isGenerating: boolean) => void,
40
+ setStreamingHumanMessage: (streamingHumanMessage: HumanMessage | null) => void,
41
+ setStreamingAIMessageChunks: React.Dispatch<React.SetStateAction<AIMessageChunk[]>>,
42
+ chatManager: ChatManager,
43
+ setInput: (input: string) => void,
44
+ setAttachments: (attachments: IDocument[]) => void
45
+ ) => {
46
+ if (!chatId || !input.trim() || isGenerating) return;
47
+
48
+ try {
49
+ setIsGenerating(true);
50
+
51
+ const chatInput = input;
52
+ const chatAttachments = attachments;
53
+
54
+ setInput("");
55
+ setAttachments([]);
56
+ setStreamingHumanMessage(new HumanMessage(chatInput));
57
+
58
+ const messageIterator = chatManager.chat(chatId, chatInput, chatAttachments);
59
+
60
+ for await (const event of messageIterator) {
61
+ if (event.type === "stream") {
62
+ setStreamingAIMessageChunks(prev => [...prev, event.content as AIMessageChunk]);
63
+ } else if (event.type === "end") {
64
+ setIsGenerating(false);
65
+ setStreamingHumanMessage(null);
66
+ setStreamingAIMessageChunks([]);
67
+ }
68
+ }
69
+ } catch (error) {
70
+ console.error(error);
71
+ toast.error(`Failed to send message: ${error}`);
72
+ setIsGenerating(false);
73
+ }
74
+ };
src/pages/chat/page.tsx CHANGED
@@ -1,746 +1,20 @@
1
  import React from "react";
2
- import { AIMessage, AIMessageChunk, BaseMessage, HumanMessage } from "@langchain/core/messages";
3
  import { useParams, useNavigate } from "react-router-dom";
4
  import { ChatManager } from "@/lib/chat/manager";
5
  import { useLiveQuery } from "dexie-react-hooks";
6
  import { mapStoredMessageToChatMessage } from "@langchain/core/messages";
7
- import { ChatHistoryDB, DexieChatMemory } from "@/lib/chat/memory";
8
- import { AutosizeTextarea } from "@/components/ui/autosize-textarea";
9
- import { Button } from "@/components/ui/button";
10
- import { Send, X, Check, Info, Paperclip, Upload, Link2, Loader2, Download, ClipboardCopy, RefreshCcw, Pencil } from "lucide-react";
11
- import { IDocument } from "@/lib/document/types";
12
- import {
13
- Dialog,
14
- DialogContent,
15
- DialogDescription,
16
- DialogHeader,
17
- DialogTitle,
18
- } from "@/components/ui/dialog";
19
- import {
20
- DropdownMenu,
21
- DropdownMenuContent,
22
- DropdownMenuItem,
23
- DropdownMenuTrigger,
24
- DropdownMenuSeparator,
25
- } from "@/components/ui/dropdown-menu";
26
- import {
27
- Tooltip,
28
- TooltipContent,
29
- TooltipProvider,
30
- TooltipTrigger,
31
- } from "@/components/ui/tooltip";
32
- import { Input as InputField } from "@/components/ui/input";
33
- import { CHAT_MODELS } from "@/lib/config/types";
34
  import { ConfigManager } from "@/lib/config/manager";
35
- import { Badge } from "@/components/ui/badge";
36
  import { DocumentManager } from "@/lib/document/manager";
37
  import { toast } from "sonner";
38
- import { isHumanMessage, isAIMessage } from "@langchain/core/messages";
39
- import { ScrollArea } from "@/components/ui/scroll-area";
40
- import ReactMarkdown from "react-markdown";
41
- import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
42
- import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
43
- import remarkMath from "remark-math";
44
- import rehypeKatex from "rehype-katex";
45
- import remarkGfm from "remark-gfm";
46
- import "katex/dist/katex.min.css";
47
- // import { Components } from "react-markdown";
48
- // import type { SyntaxHighlighterProps } from 'react-syntax-highlighter';
49
- // import type { CodeProps } from 'react-markdown/lib/ast-to-react';
50
- import { cn } from "@/lib/utils";
51
-
52
- // Types
53
- interface MessageProps {
54
- message: BaseMessage;
55
- documentManager?: DocumentManager;
56
- setPreviewDocument?: (document: IDocument | null) => void;
57
- previewDocument?: IDocument | null;
58
- }
59
-
60
- interface MessagesProps {
61
- messages: BaseMessage[] | undefined;
62
- streamingHumanMessage: HumanMessage | null;
63
- streamingAIMessageChunks: AIMessageChunk[];
64
- setPreviewDocument: (document: IDocument | null) => void;
65
- }
66
-
67
- interface InputProps {
68
- input: string;
69
- selectedModel: string;
70
- attachments: IDocument[];
71
- enabledChatModels: string[] | undefined;
72
- isUrlInputOpen: boolean;
73
- urlInput: string;
74
- selectedModelName: string;
75
- isGenerating: boolean;
76
- onInputChange: (value: string) => void;
77
- onModelChange: (model: string) => void;
78
- onSendMessage: () => void;
79
- setPreviewDocument: (doc: IDocument | null) => void;
80
- setIsUrlInputOpen: (open: boolean) => void;
81
- setUrlInput: (url: string) => void;
82
- handleAttachmentFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
83
- handleAttachmentUrlUpload: () => void;
84
- handleAttachmentRemove: (docId: string) => void;
85
- }
86
-
87
- interface FilePreviewDialogProps {
88
- document: IDocument | null;
89
- onClose: () => void;
90
- }
91
-
92
- // Components
93
- const DocumentBadge = React.memo(({
94
- document,
95
- onPreview,
96
- onRemove,
97
- removeable = true
98
- }: {
99
- document: IDocument;
100
- onPreview: () => void;
101
- onRemove: () => void;
102
- removeable?: boolean;
103
- }) => (
104
- <Badge
105
- key={document.id}
106
- className="gap-1 cursor-pointer justify-between max-w-[200px] w-fit truncate bg-muted-foreground/20 hover:bg-primary/5"
107
- onClick={onPreview}
108
- >
109
- <span className="truncate">{document.name}</span>
110
- {removeable && (
111
- <Button
112
- variant="ghost"
113
- size="icon"
114
- className="h-4 w-4"
115
- onClick={(e) => {
116
- e.stopPropagation();
117
- onRemove();
118
- }}
119
- >
120
- <X className="h-3 w-3" />
121
- </Button>
122
- )}
123
- </Badge>
124
- ));
125
- DocumentBadge.displayName = "DocumentBadge";
126
-
127
- const DocumentBadgesScrollArea = React.memo(({
128
- documents,
129
- onPreview,
130
- onRemove,
131
- removeable = true,
132
- maxHeight = "100px",
133
- className = ""
134
- }: {
135
- documents: IDocument[];
136
- onPreview: (doc: IDocument) => void;
137
- onRemove: (docId: string) => void;
138
- removeable?: boolean;
139
- maxHeight?: string;
140
- className?: string;
141
- }) => (
142
- <ScrollArea className={cn("w-full", className)} style={{ maxHeight }}>
143
- <div className="flex flex-row-reverse flex-wrap-reverse gap-1 p-1">
144
- {documents.map((document) => (
145
- <DocumentBadge
146
- key={document.id}
147
- document={document}
148
- onPreview={() => onPreview(document)}
149
- onRemove={() => onRemove(document.id)}
150
- removeable={removeable}
151
- />
152
- ))}
153
- </div>
154
- </ScrollArea>
155
- ));
156
- DocumentBadgesScrollArea.displayName = "DocumentBadgesScrollArea";
157
-
158
- const HumanMessageComponent = React.memo(({ message, setPreviewDocument, onEdit, onRegenerate, isEditing, onSave, onCancelEdit }: MessageProps & {
159
- onEdit?: () => void;
160
- onRegenerate?: () => void;
161
- isEditing?: boolean;
162
- onSave?: (content: string) => void;
163
- onCancelEdit?: () => void;
164
- }) => {
165
- const [editedContent, setEditedContent] = React.useState(String(message.content));
166
-
167
- if (message.response_metadata?.documents?.length) {
168
- return (
169
- <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end">
170
- <DocumentBadgesScrollArea
171
- documents={message.response_metadata.documents}
172
- onPreview={(doc) => setPreviewDocument?.(doc)}
173
- onRemove={() => {}}
174
- removeable={false}
175
- maxHeight="200px"
176
- />
177
- </div>
178
- );
179
- }
180
-
181
- return (
182
- <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end group">
183
- {isEditing ? (
184
- <div className="flex flex-col gap-2 w-full">
185
- <AutosizeTextarea
186
- value={editedContent}
187
- onChange={(e) => setEditedContent(e.target.value)}
188
- className="bg-muted p-5 rounded-md"
189
- maxHeight={300}
190
- />
191
- <div className="flex gap-2 justify-end">
192
- <Button
193
- variant="ghost"
194
- size="sm"
195
- onClick={onCancelEdit}
196
- >
197
- <X className="h-4 w-4 mr-2" />
198
- Cancel
199
- </Button>
200
- <Button
201
- size="sm"
202
- onClick={() => onSave?.(editedContent)}
203
- >
204
- <Check className="h-4 w-4 mr-2" />
205
- Save
206
- </Button>
207
- </div>
208
- </div>
209
- ) : (
210
- <>
211
- <div className="flex p-5 bg-muted rounded-md" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
212
- {String(message.content)}
213
- </div>
214
- <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
215
- <Button variant="ghost" size="icon" onClick={onRegenerate}>
216
- <RefreshCcw className="h-4 w-4" />
217
- </Button>
218
- <Button variant="ghost" size="icon" onClick={onEdit}>
219
- <Pencil className="h-4 w-4" />
220
- </Button>
221
- <Button
222
- variant="ghost"
223
- size="icon"
224
- onClick={() => navigator.clipboard.writeText(String(message.content)).then(() => toast.success("Message copied to clipboard"))}
225
- >
226
- <ClipboardCopy className="h-4 w-4" />
227
- </Button>
228
- </div>
229
- </>
230
- )}
231
- </div>
232
- );
233
- });
234
- HumanMessageComponent.displayName = "HumanMessageComponent";
235
-
236
- const AIMessageComponent = React.memo(({ message }: MessageProps) => {
237
- const handleCopy = React.useCallback(() => {
238
- const content = String(message.content);
239
- navigator.clipboard.writeText(content)
240
- .then(() => toast.success("Response copied to clipboard"))
241
- .catch(() => toast.error("Failed to copy response"));
242
- }, [message.content]);
243
-
244
- return (
245
- <div className="flex flex-col gap-1 group">
246
- <ReactMarkdown
247
- remarkPlugins={[remarkMath, remarkGfm]}
248
- rehypePlugins={[rehypeKatex]}
249
- components={{
250
- code(props) {
251
- const {children, className, ...rest} = props;
252
- const match = /language-(\w+)/.exec(className || '');
253
- const language = match ? match[1] : '';
254
- const code = String(children).replace(/\n$/, '');
255
-
256
- const copyToClipboard = () => {
257
- navigator.clipboard.writeText(code);
258
- toast.success("Code copied to clipboard");
259
- };
260
-
261
- return match ? (
262
- <div className="relative rounded-md overflow-hidden">
263
- <div className="absolute right-2 top-2 flex items-center gap-2">
264
- {language && (
265
- <Badge variant="secondary" className="text-xs font-mono">
266
- {language}
267
- </Badge>
268
- )}
269
- <Button
270
- variant="ghost"
271
- size="icon"
272
- className="h-6 w-6 bg-muted/50 hover:bg-muted"
273
- onClick={copyToClipboard}
274
- >
275
- <ClipboardCopy className="h-3 w-3" />
276
- </Button>
277
- </div>
278
- <SyntaxHighlighter
279
- style={oneDark}
280
- language={language}
281
- PreTag="div"
282
- customStyle={{ margin: 0, borderRadius: 0 }}
283
- >
284
- {code}
285
- </SyntaxHighlighter>
286
- </div>
287
- ) : (
288
- <code {...rest} className={`${className} bg-muted px-1.5 py-0.5 rounded-md`}>
289
- {children}
290
- </code>
291
- );
292
- },
293
- a(props) {
294
- return <a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />;
295
- },
296
- table(props) {
297
- return <table {...props} className="border-collapse table-auto w-full" />;
298
- },
299
- th(props) {
300
- return <th {...props} className="border border-muted-foreground px-4 py-2 text-left" />;
301
- },
302
- td(props) {
303
- return <td {...props} className="border border-muted-foreground px-4 py-2" />;
304
- },
305
- blockquote(props) {
306
- return <blockquote {...props} className="border-l-4 border-primary pl-4 italic" />;
307
- },
308
- ul(props) {
309
- return <ul {...props} className="list-disc list-inside" />;
310
- },
311
- ol(props) {
312
- return <ol {...props} className="list-decimal list-inside" />;
313
- },
314
- }}
315
- >
316
- {String(message.content)}
317
- </ReactMarkdown>
318
- <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
319
- <Button variant="ghost" size="sm" onClick={handleCopy}>
320
- <ClipboardCopy className="h-4 w-4 mr-2" />
321
- Copy response
322
- </Button>
323
- </div>
324
- </div>
325
- );
326
- });
327
- AIMessageComponent.displayName = "AIMessageComponent";
328
-
329
- const Messages = React.memo(({
330
- messages,
331
- streamingHumanMessage,
332
- streamingAIMessageChunks,
333
- setPreviewDocument,
334
- onEditMessage,
335
- onRegenerateMessage,
336
- editingMessageIndex,
337
- onSaveEdit,
338
- onCancelEdit,
339
- }: MessagesProps & {
340
- onEditMessage: (index: number) => void;
341
- onRegenerateMessage: (index: number) => void;
342
- editingMessageIndex: number | null;
343
- onSaveEdit: (content: string) => void;
344
- onCancelEdit: () => void;
345
- }) => {
346
- const { id } = useParams();
347
-
348
- if (id === "new" || !messages) {
349
- return <ScrollArea className="flex-1" />;
350
- }
351
-
352
- return (
353
- <ScrollArea className="flex-1">
354
- <div className="flex flex-col w-1/2 mx-auto gap-1">
355
- {messages.map((message, index) => {
356
- if (isHumanMessage(message)) {
357
- return (
358
- <HumanMessageComponent
359
- key={index}
360
- message={message}
361
- setPreviewDocument={setPreviewDocument}
362
- onEdit={() => onEditMessage(index)}
363
- onRegenerate={() => onRegenerateMessage(index)}
364
- isEditing={editingMessageIndex === index}
365
- onSave={onSaveEdit}
366
- onCancelEdit={onCancelEdit}
367
- />
368
- );
369
- }
370
- if (isAIMessage(message)) {
371
- return <AIMessageComponent key={index} message={message} />;
372
- }
373
- return null;
374
- })}
375
- {streamingHumanMessage && (
376
- <HumanMessageComponent
377
- message={streamingHumanMessage}
378
- setPreviewDocument={setPreviewDocument}
379
- />
380
- )}
381
- {streamingAIMessageChunks.length > 0 && (
382
- <AIMessageComponent
383
- message={new AIMessage(streamingAIMessageChunks.map(chunk => chunk.content).join(""))}
384
- />
385
- )}
386
- </div>
387
- </ScrollArea>
388
- );
389
- });
390
- Messages.displayName = "Messages";
391
-
392
- const FilePreviewDialog = React.memo(({ document: fileDoc, onClose }: FilePreviewDialogProps) => {
393
- const [content, setContent] = React.useState<string | null>(null);
394
- const [isLoading, setIsLoading] = React.useState(true);
395
- const [error, setError] = React.useState<string | null>(null);
396
- const documentManager = React.useMemo(() => DocumentManager.getInstance(), []);
397
-
398
- React.useEffect(() => {
399
- if (!fileDoc) return;
400
-
401
- const loadContent = async () => {
402
- try {
403
- setIsLoading(true);
404
- setError(null);
405
- const file = await documentManager.getDocument(fileDoc.id);
406
-
407
- if (fileDoc.type === "image") {
408
- const reader = new FileReader();
409
- reader.onload = () => setContent(reader.result as string);
410
- reader.readAsDataURL(file);
411
- } else if (fileDoc.type === "pdf") {
412
- const url = URL.createObjectURL(file);
413
- setContent(url);
414
- return () => URL.revokeObjectURL(url);
415
- } else {
416
- const text = await file.text();
417
- setContent(text);
418
- }
419
- } catch (err) {
420
- console.error(err);
421
- setError("Failed to load file content");
422
- } finally {
423
- setIsLoading(false);
424
- }
425
- };
426
-
427
- loadContent();
428
- }, [fileDoc, documentManager]);
429
-
430
- const handleDownload = React.useCallback(async () => {
431
- if (!fileDoc) return;
432
- try {
433
- const file = await documentManager.getDocument(fileDoc.id);
434
- const url = URL.createObjectURL(file);
435
- const a = document.createElement('a');
436
- a.href = url;
437
- a.download = fileDoc.name;
438
- document.body.appendChild(a);
439
- a.click();
440
- document.body.removeChild(a);
441
- URL.revokeObjectURL(url);
442
- } catch (err) {
443
- console.error(err);
444
- toast.error("Failed to download file");
445
- }
446
- }, [fileDoc, documentManager]);
447
-
448
- if (!fileDoc) return null;
449
-
450
- const renderContent = () => {
451
- if (isLoading) {
452
- return (
453
- <div className="flex items-center justify-center h-full">
454
- <Loader2 className="h-8 w-8 animate-spin" />
455
- </div>
456
- );
457
- }
458
- if (error) {
459
- return (
460
- <div className="flex items-center justify-center h-full text-destructive">
461
- {error}
462
- </div>
463
- );
464
- }
465
- if (content) {
466
- if (fileDoc.type === "image") {
467
- return (
468
- <div className="flex items-center justify-center p-4">
469
- <img src={content} alt={fileDoc.name} className="max-w-full max-h-full object-contain" />
470
- </div>
471
- );
472
- }
473
- if (fileDoc.type === "pdf") {
474
- return <iframe src={content} className="h-full w-full rounded-md bg-muted/50" />;
475
- }
476
- return (
477
- <pre className="p-4 whitespace-pre-wrap font-mono text-sm">
478
- {content}
479
- </pre>
480
- );
481
- }
482
- return null;
483
- };
484
-
485
- return (
486
- <Dialog open={!!fileDoc} onOpenChange={onClose}>
487
- <DialogContent className="max-w-4xl max-h-[80vh] h-full flex flex-col">
488
- <DialogHeader className="flex flex-row items-center justify-between">
489
- <div className="space-y-1">
490
- <DialogTitle>{fileDoc.name}</DialogTitle>
491
- <DialogDescription>
492
- Type: {fileDoc.type} • Created: {new Date(fileDoc.createdAt).toLocaleString()}
493
- </DialogDescription>
494
- </div>
495
- <Button onClick={handleDownload} variant="outline" size="sm" className="gap-2">
496
- <Download className="h-4 w-4" />
497
- Download
498
- </Button>
499
- </DialogHeader>
500
- {renderContent()}
501
- </DialogContent>
502
- </Dialog>
503
- );
504
- });
505
- FilePreviewDialog.displayName = "FilePreviewDialog";
506
-
507
- const ModelSelector = React.memo(({
508
- selectedModel,
509
- selectedModelName,
510
- enabledChatModels,
511
- onModelChange
512
- }: {
513
- selectedModel: string;
514
- selectedModelName: string;
515
- enabledChatModels: string[] | undefined;
516
- onModelChange: (model: string) => void;
517
- }) => (
518
- <DropdownMenu>
519
- <DropdownMenuTrigger asChild>
520
- <Button
521
- variant="ghost"
522
- className="w-[200px] h-8 p-1 justify-start font-normal"
523
- >
524
- {selectedModelName}
525
- </Button>
526
- </DropdownMenuTrigger>
527
- <DropdownMenuContent className="w-[400px]">
528
- {CHAT_MODELS
529
- .filter((model) => enabledChatModels?.includes(model.model))
530
- .map((model) => (
531
- <TooltipProvider key={model.model}>
532
- <Tooltip>
533
- <TooltipTrigger asChild>
534
- <DropdownMenuItem
535
- className="p-4"
536
- onSelect={() => onModelChange(model.model)}
537
- >
538
- <div className="flex items-center gap-2 w-full">
539
- {model.model === selectedModel && (
540
- <Check className="h-4 w-4 shrink-0" />
541
- )}
542
- <span className="flex-grow">{model.name}</span>
543
- <Info className="h-4 w-4 text-muted-foreground shrink-0" />
544
- </div>
545
- </DropdownMenuItem>
546
- </TooltipTrigger>
547
- <TooltipContent side="right" align="start" className="max-w-[300px]">
548
- <p>{model.description}</p>
549
- {model.modalities && model.modalities.length > 0 && (
550
- <div className="flex gap-1 flex-wrap mt-1">
551
- {model.modalities.map((modality) => (
552
- <Badge key={modality} variant="secondary" className="capitalize text-xs">
553
- {modality.toLowerCase()}
554
- </Badge>
555
- ))}
556
- </div>
557
- )}
558
- </TooltipContent>
559
- </Tooltip>
560
- </TooltipProvider>
561
- ))}
562
- </DropdownMenuContent>
563
- </DropdownMenu>
564
- ));
565
- ModelSelector.displayName = "ModelSelector";
566
-
567
- const AttachmentDropdown = React.memo(({
568
- isUrlInputOpen,
569
- setIsUrlInputOpen,
570
- urlInput,
571
- setUrlInput,
572
- handleAttachmentFileUpload,
573
- handleAttachmentUrlUpload
574
- }: {
575
- isUrlInputOpen: boolean;
576
- setIsUrlInputOpen: (open: boolean) => void;
577
- urlInput: string;
578
- setUrlInput: (url: string) => void;
579
- handleAttachmentFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
580
- handleAttachmentUrlUpload: () => void;
581
- }) => (
582
- <DropdownMenu>
583
- <DropdownMenuTrigger asChild>
584
- <Button
585
- variant="ghost"
586
- size="icon"
587
- className="h-8 w-8"
588
- >
589
- <Paperclip className="h-4 w-4" />
590
- </Button>
591
- </DropdownMenuTrigger>
592
- <DropdownMenuContent align="start" className="w-[300px]">
593
- <DropdownMenuItem
594
- onSelect={() => {
595
- const input = document.createElement("input");
596
- input.type = "file";
597
- input.multiple = true;
598
- input.accept = ".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.gif,.mp3,.mp4,.wav,.ogg";
599
- input.onchange = (e) => handleAttachmentFileUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
600
- input.click();
601
- }}
602
- >
603
- <Upload className="h-4 w-4 mr-2" />
604
- Upload Files
605
- </DropdownMenuItem>
606
- <DropdownMenuSeparator />
607
- <DropdownMenuItem onSelect={(e) => {
608
- e.preventDefault();
609
- setIsUrlInputOpen(true);
610
- }}>
611
- <Link2 className="h-4 w-4 mr-2" />
612
- Add URLs
613
- </DropdownMenuItem>
614
- {isUrlInputOpen && (
615
- <div className="p-2 flex gap-2">
616
- <InputField
617
- value={urlInput}
618
- onChange={(e) => setUrlInput(e.target.value)}
619
- placeholder="Enter URLs (comma-separated)"
620
- className="flex-1"
621
- />
622
- <Button size="sm" onClick={handleAttachmentUrlUpload}>
623
- Add
624
- </Button>
625
- </div>
626
- )}
627
- </DropdownMenuContent>
628
- </DropdownMenu>
629
- ));
630
- AttachmentDropdown.displayName = "AttachmentDropdown";
631
-
632
- const Input = React.memo(({
633
- input,
634
- selectedModel,
635
- attachments,
636
- onInputChange,
637
- onModelChange,
638
- onSendMessage,
639
- enabledChatModels,
640
- setPreviewDocument,
641
- isUrlInputOpen,
642
- setIsUrlInputOpen,
643
- urlInput,
644
- setUrlInput,
645
- handleAttachmentFileUpload,
646
- handleAttachmentUrlUpload,
647
- handleAttachmentRemove,
648
- selectedModelName,
649
- isGenerating,
650
- }: InputProps) => {
651
- return (
652
- <div className="flex flex-col w-1/2 mx-auto bg-muted rounded-md p-1">
653
- {attachments.length > 0 && (
654
- <DocumentBadgesScrollArea
655
- documents={attachments}
656
- onPreview={setPreviewDocument}
657
- onRemove={handleAttachmentRemove}
658
- maxHeight="100px"
659
- />
660
- )}
661
- <AutosizeTextarea
662
- value={input}
663
- onChange={(e) => onInputChange(e.target.value)}
664
- className="bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 resize-none p-0"
665
- maxHeight={300}
666
- minHeight={50}
667
- onKeyDown={(e) => {
668
- if (e.key === "Enter" && !e.shiftKey) {
669
- e.preventDefault();
670
- onSendMessage();
671
- }
672
- }}
673
- />
674
- <div className="flex flex-row justify-between items-end">
675
- <div className="flex gap-0">
676
- <ModelSelector
677
- selectedModel={selectedModel}
678
- selectedModelName={selectedModelName}
679
- enabledChatModels={enabledChatModels}
680
- onModelChange={onModelChange}
681
- />
682
- <AttachmentDropdown
683
- isUrlInputOpen={isUrlInputOpen}
684
- setIsUrlInputOpen={setIsUrlInputOpen}
685
- urlInput={urlInput}
686
- setUrlInput={setUrlInput}
687
- handleAttachmentFileUpload={handleAttachmentFileUpload}
688
- handleAttachmentUrlUpload={handleAttachmentUrlUpload}
689
- />
690
- </div>
691
- <Button
692
- variant="default"
693
- size="icon"
694
- className="rounded-full"
695
- onClick={onSendMessage}
696
- disabled={isGenerating}
697
- >
698
- {isGenerating ? (
699
- <Loader2 className="h-4 w-4 animate-spin" />
700
- ) : (
701
- <Send className="h-4 w-4" />
702
- )}
703
- </Button>
704
- </div>
705
- </div>
706
- );
707
- });
708
- Input.displayName = "Input";
709
-
710
- // Custom hooks
711
- const useChatSession = (id: string | undefined) => {
712
- const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []);
713
- return useLiveQuery(async () => {
714
- if (!id || id === "new") return null;
715
- return await chatHistoryDB.sessions.get(id);
716
- }, [id]);
717
- };
718
-
719
- interface Config {
720
- default_chat_model: string;
721
- enabled_chat_models: string[];
722
- }
723
-
724
- interface ChatSession {
725
- model: string;
726
- updatedAt: number;
727
- }
728
-
729
- const useSelectedModel = (id: string | undefined, config: Config | undefined) => {
730
- const [selectedModel, setSelectedModel] = React.useState<string | null>(null);
731
- const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []);
732
-
733
- useLiveQuery(async () => {
734
- if (!config) return;
735
-
736
- const model = !id || id === "new"
737
- ? config.default_chat_model
738
- : (await chatHistoryDB.sessions.get(id) as ChatSession | undefined)?.model ?? config.default_chat_model;
739
- setSelectedModel(model);
740
- }, [id, config]);
741
-
742
- return [selectedModel, setSelectedModel, chatHistoryDB] as const;
743
- };
744
 
745
  export function ChatPage() {
746
  const { id } = useParams();
@@ -791,40 +65,25 @@ export function ChatPage() {
791
 
792
  const handleSendMessage = React.useCallback(async () => {
793
  let chatId = id;
 
794
  if (id === "new") {
795
  chatId = crypto.randomUUID();
796
  new DexieChatMemory(chatId);
 
797
  navigate(`/chat/${chatId}`, { replace: true });
798
  }
799
- if (!chatId || !input.trim() || isGenerating) return;
800
-
801
- try {
802
- setIsGenerating(true);
803
-
804
- const chatInput = input;
805
- const chatAttachments = attachments;
806
-
807
- setInput("");
808
- setAttachments([]);
809
- setStreamingHumanMessage(new HumanMessage(chatInput));
810
 
811
- const messageIterator = chatManager.chat(chatId, chatInput, chatAttachments);
812
-
813
- for await (const event of messageIterator) {
814
- if (event.type === "stream") {
815
- setStreamingAIMessageChunks(prev => [...prev, event.content]);
816
- } else if (event.type === "end") {
817
- setIsGenerating(false);
818
- setStreamingHumanMessage(null);
819
- setStreamingAIMessageChunks([]);
820
- }
821
- }
822
- } catch (error) {
823
- console.error(error);
824
- toast.error(`Failed to send message: ${error}`);
825
- setIsGenerating(false);
826
  }
827
- }, [id, input, isGenerating, attachments, chatManager, navigate]);
828
 
829
  const handleAttachmentFileUpload = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
830
  const files = event.target.files;
@@ -869,45 +128,29 @@ export function ChatPage() {
869
  const handleSaveEdit = React.useCallback(async (content: string) => {
870
  if (!id || editingMessageIndex === null || !chatSession || isGenerating) return;
871
 
872
- try {
873
- // Update the message directly in the database
874
- const updatedMessages = [...chatSession.messages];
875
- updatedMessages[editingMessageIndex] = {
876
- ...updatedMessages[editingMessageIndex],
877
- data: {
878
- ...updatedMessages[editingMessageIndex].data,
879
- content
880
- }
881
- };
882
- // Remove messages after the edited message
883
- const newMessages = updatedMessages.slice(0, editingMessageIndex);
884
-
885
- await chatHistoryDB.sessions.update(id, {
886
- ...chatSession,
887
- messages: newMessages,
888
- updatedAt: Date.now()
889
- });
890
-
891
- setInput(content);
892
- setEditingMessageIndex(null);
893
- setAttachments([]);
894
-
895
- setIsGenerating(true);
896
- const messageIterator = chatManager.chat(id, content);
897
-
898
- for await (const event of messageIterator) {
899
- if (event.type === "stream") {
900
- setStreamingAIMessageChunks(prev => [...prev, event.content]);
901
- } else if (event.type === "end") {
902
- setIsGenerating(false);
903
- setStreamingAIMessageChunks([]);
904
- }
905
  }
906
- } catch (error) {
907
- console.error("Failed to save edit:", error);
908
- toast.error("Failed to save edit");
909
- setIsGenerating(false);
910
- }
 
 
 
 
 
 
 
 
 
 
911
  }, [id, editingMessageIndex, chatSession, isGenerating, chatHistoryDB.sessions, chatManager]);
912
 
913
  const handleRegenerateMessage = React.useCallback(async (index: number) => {
@@ -928,17 +171,7 @@ export function ChatPage() {
928
  updatedAt: Date.now()
929
  });
930
 
931
- setIsGenerating(true);
932
- const messageIterator = chatManager.chat(id, content);
933
-
934
- for await (const event of messageIterator) {
935
- if (event.type === "stream") {
936
- setStreamingAIMessageChunks(prev => [...prev, event.content]);
937
- } else if (event.type === "end") {
938
- setIsGenerating(false);
939
- setStreamingAIMessageChunks([]);
940
- }
941
- }
942
  }, [id, chatSession, isGenerating, chatHistoryDB.sessions, chatManager]);
943
 
944
  return (
 
1
  import React from "react";
 
2
  import { useParams, useNavigate } from "react-router-dom";
3
  import { ChatManager } from "@/lib/chat/manager";
4
  import { useLiveQuery } from "dexie-react-hooks";
5
  import { mapStoredMessageToChatMessage } from "@langchain/core/messages";
6
+ import { DexieChatMemory } from "@/lib/chat/memory";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import { ConfigManager } from "@/lib/config/manager";
 
8
  import { DocumentManager } from "@/lib/document/manager";
9
  import { toast } from "sonner";
10
+ import { Messages } from "./components/Messages";
11
+ import { Input } from "./components/Input";
12
+ import { FilePreviewDialog } from "./components/FilePreviewDialog";
13
+ import { useChatSession, useSelectedModel, generateMessage } from "./hooks";
14
+ import { CHAT_MODELS } from "@/lib/config/types";
15
+ import { IDocument } from "@/lib/document/types";
16
+ import { HumanMessage } from "@langchain/core/messages";
17
+ import { AIMessageChunk } from "@langchain/core/messages";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  export function ChatPage() {
20
  const { id } = useParams();
 
65
 
66
  const handleSendMessage = React.useCallback(async () => {
67
  let chatId = id;
68
+ let isNewChat = false;
69
  if (id === "new") {
70
  chatId = crypto.randomUUID();
71
  new DexieChatMemory(chatId);
72
+ isNewChat = true;
73
  navigate(`/chat/${chatId}`, { replace: true });
74
  }
75
+ await generateMessage(chatId, input, attachments, isGenerating, setIsGenerating, setStreamingHumanMessage, setStreamingAIMessageChunks, chatManager, setInput, setAttachments);
 
 
 
 
 
 
 
 
 
 
76
 
77
+ if (isNewChat && chatId) {
78
+ const chatName = await chatManager.chatChain(
79
+ `Based on this user message, generate a very concise (max 40 chars) but descriptive name for this chat: "${input}"`,
80
+ "You are a helpful assistant that generates concise chat names. Respond only with the name, no quotes or explanation."
81
+ );
82
+ await chatHistoryDB.sessions.update(chatId, {
83
+ name: String(chatName.content)
84
+ });
 
 
 
 
 
 
 
85
  }
86
+ }, [id, input, attachments, isGenerating, chatManager, navigate, chatHistoryDB.sessions]);
87
 
88
  const handleAttachmentFileUpload = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
89
  const files = event.target.files;
 
128
  const handleSaveEdit = React.useCallback(async (content: string) => {
129
  if (!id || editingMessageIndex === null || !chatSession || isGenerating) return;
130
 
131
+ // Update the message directly in the database
132
+ const updatedMessages = [...chatSession.messages];
133
+ updatedMessages[editingMessageIndex] = {
134
+ ...updatedMessages[editingMessageIndex],
135
+ data: {
136
+ ...updatedMessages[editingMessageIndex].data,
137
+ content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
+ };
140
+ // Remove messages after the edited message
141
+ const newMessages = updatedMessages.slice(0, editingMessageIndex);
142
+
143
+ await chatHistoryDB.sessions.update(id, {
144
+ ...chatSession,
145
+ messages: newMessages,
146
+ updatedAt: Date.now()
147
+ });
148
+
149
+ setInput(content);
150
+ setEditingMessageIndex(null);
151
+ setAttachments([]);
152
+
153
+ generateMessage(id, content, [], isGenerating, setIsGenerating, setStreamingHumanMessage, setStreamingAIMessageChunks, chatManager, setInput, setAttachments);
154
  }, [id, editingMessageIndex, chatSession, isGenerating, chatHistoryDB.sessions, chatManager]);
155
 
156
  const handleRegenerateMessage = React.useCallback(async (index: number) => {
 
171
  updatedAt: Date.now()
172
  });
173
 
174
+ generateMessage(id, content, [], isGenerating, setIsGenerating, setStreamingHumanMessage, setStreamingAIMessageChunks, chatManager, setInput, setAttachments);
 
 
 
 
 
 
 
 
 
 
175
  }, [id, chatSession, isGenerating, chatHistoryDB.sessions, chatManager]);
176
 
177
  return (
src/pages/chat/page2.tsx DELETED
@@ -1,962 +0,0 @@
1
- import React from 'react';
2
- import { useParams, useNavigate } from 'react-router-dom';
3
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
- import { useVirtualizer } from '@tanstack/react-virtual';
5
- import { AIMessageChunk, BaseMessage, HumanMessage } from '@langchain/core/messages';
6
- import { mapStoredMessageToChatMessage } from '@langchain/core/messages';
7
- import { ChatManager } from '@/lib/chat/manager';
8
- import { ChatHistoryDB, DexieChatMemory } from '@/lib/chat/memory';
9
- import { ConfigManager } from '@/lib/config/manager';
10
- import { DocumentManager } from '@/lib/document/manager';
11
- import { IDocument } from '@/lib/document/types';
12
- import { CHAT_MODELS } from '@/lib/config/types';
13
- import { toast } from 'sonner';
14
- import { cn } from "@/lib/utils";
15
-
16
- // Components
17
- import { AutosizeTextarea } from '@/components/ui/autosize-textarea';
18
- import { Button } from '@/components/ui/button';
19
- import { ScrollArea } from '@/components/ui/scroll-area';
20
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
21
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
22
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
23
- import { Input } from '@/components/ui/input';
24
- import { Badge } from '@/components/ui/badge';
25
-
26
- // Icons
27
- import { Send, X, Check, Info, Paperclip, Upload, Link2, Loader2, Download, ClipboardCopy, RefreshCcw, Pencil } from 'lucide-react';
28
-
29
- // Markdown
30
- import ReactMarkdown from 'react-markdown';
31
- import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
32
- import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
33
- import remarkMath from 'remark-math';
34
- import rehypeKatex from 'rehype-katex';
35
- import remarkGfm from 'remark-gfm';
36
- import 'katex/dist/katex.min.css';
37
-
38
- // Types
39
- interface ChatState {
40
- input: string;
41
- attachments: IDocument[];
42
- isUrlInputOpen: boolean;
43
- urlInput: string;
44
- previewDocument: IDocument | null;
45
- isGenerating: boolean;
46
- streamingHumanMessage: HumanMessage | null;
47
- streamingAIMessageChunks: AIMessageChunk[];
48
- editingMessageIndex: number | null;
49
- }
50
-
51
- // Query Keys
52
- const chatKeys = {
53
- all: ['chat'] as const,
54
- session: (id: string) => [...chatKeys.all, 'session', id] as const,
55
- config: ['config'] as const,
56
- };
57
-
58
- // Custom Hooks
59
- const useChatSession = (id: string | undefined) => {
60
- const chatHistoryDB = React.useMemo(() => new ChatHistoryDB(), []);
61
-
62
- return useQuery({
63
- queryKey: chatKeys.session(id || 'new'),
64
- queryFn: async () => {
65
- if (!id || id === 'new') return null;
66
- return await chatHistoryDB.sessions.get(id);
67
- },
68
- staleTime: 1000 * 60, // 1 minute
69
- });
70
- };
71
-
72
- const useConfig = () => {
73
- const configManager = React.useMemo(() => ConfigManager.getInstance(), []);
74
-
75
- return useQuery({
76
- queryKey: chatKeys.config,
77
- queryFn: async () => await configManager.getConfig(),
78
- staleTime: 1000 * 60 * 5, // 5 minutes
79
- });
80
- };
81
-
82
- // Optimized Components
83
- const MessageContent = React.memo(({ content }: { content: string }) => (
84
- <ReactMarkdown
85
- remarkPlugins={[remarkMath, remarkGfm]}
86
- rehypePlugins={[rehypeKatex]}
87
- components={{
88
- code(props) {
89
- const {children, className, ...rest} = props;
90
- const match = /language-(\w+)/.exec(className || '');
91
- const language = match ? match[1] : '';
92
- const code = String(children).replace(/\n$/, '');
93
-
94
- const copyToClipboard = () => {
95
- navigator.clipboard.writeText(code);
96
- toast.success('Code copied to clipboard');
97
- };
98
-
99
- return match ? (
100
- <div className="relative rounded-md overflow-hidden">
101
- <div className="absolute right-2 top-2 flex items-center gap-2">
102
- {language && (
103
- <Badge variant="secondary" className="text-xs font-mono">
104
- {language}
105
- </Badge>
106
- )}
107
- <Button
108
- variant="ghost"
109
- size="icon"
110
- className="h-6 w-6 bg-muted/50 hover:bg-muted"
111
- onClick={copyToClipboard}
112
- >
113
- <ClipboardCopy className="h-3 w-3" />
114
- </Button>
115
- </div>
116
- <SyntaxHighlighter
117
- style={oneDark}
118
- language={language}
119
- PreTag="div"
120
- customStyle={{ margin: 0, borderRadius: 0 }}
121
- >
122
- {code}
123
- </SyntaxHighlighter>
124
- </div>
125
- ) : (
126
- <code {...rest} className={`${className} bg-muted px-1.5 py-0.5 rounded-md`}>
127
- {children}
128
- </code>
129
- );
130
- },
131
- }}
132
- >
133
- {content}
134
- </ReactMarkdown>
135
- ));
136
- MessageContent.displayName = 'MessageContent';
137
-
138
- const VirtualizedMessages = React.memo(({
139
- messages,
140
- streamingMessages,
141
- parentRef,
142
- onEditMessage,
143
- onRegenerateMessage,
144
- editingMessageIndex,
145
- onSaveEdit,
146
- onCancelEdit,
147
- }: {
148
- messages: BaseMessage[];
149
- streamingMessages: {
150
- human: HumanMessage | null;
151
- ai: AIMessageChunk[];
152
- };
153
- parentRef: React.MutableRefObject<HTMLDivElement | null>;
154
- onEditMessage: (index: number) => void;
155
- onRegenerateMessage: (index: number) => void;
156
- editingMessageIndex: number | null;
157
- onSaveEdit: (content: string) => void;
158
- onCancelEdit: () => void;
159
- }) => {
160
- const rowVirtualizer = useVirtualizer({
161
- count: messages.length + (streamingMessages.human ? 2 : 0),
162
- getScrollElement: () => parentRef.current,
163
- estimateSize: () => 100,
164
- overscan: 5,
165
- });
166
-
167
- if (!parentRef.current) return null;
168
-
169
- return (
170
- <div
171
- className="flex-1 overflow-auto"
172
- style={{
173
- height: `100%`,
174
- width: `100%`,
175
- contain: 'strict',
176
- }}
177
- >
178
- <div
179
- style={{
180
- height: `${rowVirtualizer.getTotalSize()}px`,
181
- width: '100%',
182
- position: 'relative',
183
- }}
184
- >
185
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
186
- const index = virtualRow.index;
187
- const message = index < messages.length
188
- ? messages[index]
189
- : index === messages.length && streamingMessages.human
190
- ? streamingMessages.human
191
- : null;
192
-
193
- if (!message) return null;
194
-
195
- const isHuman = message instanceof HumanMessage;
196
- const isEditing = editingMessageIndex === index;
197
-
198
- return (
199
- <div
200
- key={virtualRow.index}
201
- data-index={virtualRow.index}
202
- ref={rowVirtualizer.measureElement}
203
- className={`absolute top-0 left-0 w-full ${
204
- isHuman ? 'flex justify-end' : ''
205
- }`}
206
- style={{
207
- transform: `translateY(${virtualRow.start}px)`,
208
- }}
209
- >
210
- <div className={`max-w-[70%] ${isHuman ? 'ml-auto' : ''}`}>
211
- {isHuman ? (
212
- <HumanMessageComponent
213
- message={message}
214
- isEditing={isEditing}
215
- onEdit={() => onEditMessage(index)}
216
- onRegenerate={() => onRegenerateMessage(index)}
217
- onSave={onSaveEdit}
218
- onCancelEdit={onCancelEdit}
219
- />
220
- ) : (
221
- <AIMessageComponent message={message} />
222
- )}
223
- </div>
224
- </div>
225
- );
226
- })}
227
- </div>
228
- </div>
229
- );
230
- });
231
- VirtualizedMessages.displayName = 'VirtualizedMessages';
232
-
233
- const HumanMessageComponent = React.memo(({
234
- message,
235
- isEditing,
236
- onEdit,
237
- onRegenerate,
238
- onSave,
239
- onCancelEdit,
240
- }: {
241
- message: HumanMessage;
242
- isEditing?: boolean;
243
- onEdit?: () => void;
244
- onRegenerate?: () => void;
245
- onSave?: (content: string) => void;
246
- onCancelEdit?: () => void;
247
- }) => {
248
- const [editedContent, setEditedContent] = React.useState(String(message.content));
249
-
250
- if (message.response_metadata?.documents?.length) {
251
- return (
252
- <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end">
253
- <DocumentBadgesScrollArea
254
- documents={message.response_metadata.documents}
255
- onPreview={() => {}}
256
- onRemove={() => {}}
257
- removeable={false}
258
- maxHeight="200px"
259
- />
260
- </div>
261
- );
262
- }
263
-
264
- return (
265
- <div className="flex flex-col gap-1 max-w-[70%] ml-auto items-end group">
266
- {isEditing ? (
267
- <div className="flex flex-col gap-2 w-full">
268
- <AutosizeTextarea
269
- value={editedContent}
270
- onChange={(e) => setEditedContent(e.target.value)}
271
- className="bg-muted p-5 rounded-md"
272
- maxHeight={300}
273
- />
274
- <div className="flex gap-2 justify-end">
275
- <Button
276
- variant="ghost"
277
- size="sm"
278
- onClick={onCancelEdit}
279
- >
280
- <X className="h-4 w-4 mr-2" />
281
- Cancel
282
- </Button>
283
- <Button
284
- size="sm"
285
- onClick={() => onSave?.(editedContent)}
286
- >
287
- <Check className="h-4 w-4 mr-2" />
288
- Save
289
- </Button>
290
- </div>
291
- </div>
292
- ) : (
293
- <>
294
- <div className="flex p-5 bg-muted rounded-md" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
295
- {String(message.content)}
296
- </div>
297
- <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
298
- <Button variant="ghost" size="icon" onClick={onRegenerate}>
299
- <RefreshCcw className="h-4 w-4" />
300
- </Button>
301
- <Button variant="ghost" size="icon" onClick={onEdit}>
302
- <Pencil className="h-4 w-4" />
303
- </Button>
304
- <Button
305
- variant="ghost"
306
- size="icon"
307
- onClick={() => navigator.clipboard.writeText(String(message.content)).then(() => toast.success("Message copied to clipboard"))}
308
- >
309
- <ClipboardCopy className="h-4 w-4" />
310
- </Button>
311
- </div>
312
- </>
313
- )}
314
- </div>
315
- );
316
- });
317
- HumanMessageComponent.displayName = 'HumanMessageComponent';
318
-
319
- const AIMessageComponent = React.memo(({ message }: { message: BaseMessage }) => {
320
- const handleCopy = React.useCallback(() => {
321
- const content = String(message.content);
322
- navigator.clipboard.writeText(content)
323
- .then(() => toast.success("Response copied to clipboard"))
324
- .catch(() => toast.error("Failed to copy response"));
325
- }, [message.content]);
326
-
327
- return (
328
- <div className="flex flex-col gap-1 group">
329
- <MessageContent content={String(message.content)} />
330
- <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100">
331
- <Button variant="ghost" size="sm" onClick={handleCopy}>
332
- <ClipboardCopy className="h-4 w-4 mr-2" />
333
- Copy response
334
- </Button>
335
- </div>
336
- </div>
337
- );
338
- });
339
- AIMessageComponent.displayName = 'AIMessageComponent';
340
-
341
- const DocumentBadgesScrollArea = React.memo(({
342
- documents,
343
- onPreview,
344
- onRemove,
345
- removeable = true,
346
- maxHeight = "100px",
347
- className = ""
348
- }: {
349
- documents: IDocument[];
350
- onPreview: (doc: IDocument) => void;
351
- onRemove: (docId: string) => void;
352
- removeable?: boolean;
353
- maxHeight?: string;
354
- className?: string;
355
- }) => (
356
- <ScrollArea className={cn("w-full", className)} style={{ maxHeight }}>
357
- <div className="flex flex-row-reverse flex-wrap-reverse gap-1 p-1">
358
- {documents.map((document) => (
359
- <DocumentBadge
360
- key={document.id}
361
- document={document}
362
- onPreview={() => onPreview(document)}
363
- onRemove={() => onRemove(document.id)}
364
- removeable={removeable}
365
- />
366
- ))}
367
- </div>
368
- </ScrollArea>
369
- ));
370
- DocumentBadgesScrollArea.displayName = 'DocumentBadgesScrollArea';
371
-
372
- const DocumentBadge = React.memo(({
373
- document,
374
- onPreview,
375
- onRemove,
376
- removeable = true
377
- }: {
378
- document: IDocument;
379
- onPreview: () => void;
380
- onRemove: () => void;
381
- removeable?: boolean;
382
- }) => (
383
- <Badge
384
- key={document.id}
385
- className="gap-1 cursor-pointer justify-between max-w-[200px] w-fit truncate bg-muted-foreground/20 hover:bg-primary/5"
386
- onClick={onPreview}
387
- >
388
- <span className="truncate">{document.name}</span>
389
- {removeable && (
390
- <Button
391
- variant="ghost"
392
- size="icon"
393
- className="h-4 w-4"
394
- onClick={(e) => {
395
- e.stopPropagation();
396
- onRemove();
397
- }}
398
- >
399
- <X className="h-3 w-3" />
400
- </Button>
401
- )}
402
- </Badge>
403
- ));
404
- DocumentBadge.displayName = 'DocumentBadge';
405
-
406
- const FilePreviewDialog = React.memo(({ document: fileDoc, onClose }: {
407
- document: IDocument | null;
408
- onClose: () => void;
409
- }) => {
410
- const [content, setContent] = React.useState<string | null>(null);
411
- const [isLoading, setIsLoading] = React.useState(true);
412
- const [error, setError] = React.useState<string | null>(null);
413
- const documentManager = React.useMemo(() => DocumentManager.getInstance(), []);
414
-
415
- React.useEffect(() => {
416
- if (!fileDoc) return;
417
-
418
- const loadContent = async () => {
419
- try {
420
- setIsLoading(true);
421
- setError(null);
422
- const file = await documentManager.getDocument(fileDoc.id);
423
-
424
- if (fileDoc.type === "image") {
425
- const reader = new FileReader();
426
- reader.onload = () => setContent(reader.result as string);
427
- reader.readAsDataURL(file);
428
- } else if (fileDoc.type === "pdf") {
429
- const url = URL.createObjectURL(file);
430
- setContent(url);
431
- return () => URL.revokeObjectURL(url);
432
- } else {
433
- const text = await file.text();
434
- setContent(text);
435
- }
436
- } catch (err) {
437
- console.error(err);
438
- setError("Failed to load file content");
439
- } finally {
440
- setIsLoading(false);
441
- }
442
- };
443
-
444
- loadContent();
445
- }, [fileDoc, documentManager]);
446
-
447
- const handleDownload = React.useCallback(async () => {
448
- if (!fileDoc) return;
449
- try {
450
- const file = await documentManager.getDocument(fileDoc.id);
451
- const url = URL.createObjectURL(file);
452
- const a = document.createElement('a');
453
- a.href = url;
454
- a.download = fileDoc.name;
455
- document.body.appendChild(a);
456
- a.click();
457
- document.body.removeChild(a);
458
- URL.revokeObjectURL(url);
459
- } catch (err) {
460
- console.error(err);
461
- toast.error("Failed to download file");
462
- }
463
- }, [fileDoc, documentManager]);
464
-
465
- if (!fileDoc) return null;
466
-
467
- return (
468
- <Dialog open={!!fileDoc} onOpenChange={onClose}>
469
- <DialogContent className="max-w-4xl max-h-[80vh] h-full flex flex-col">
470
- <DialogHeader className="flex flex-row items-center justify-between">
471
- <div className="space-y-1">
472
- <DialogTitle>{fileDoc.name}</DialogTitle>
473
- <DialogDescription>
474
- Type: {fileDoc.type} • Created: {new Date(fileDoc.createdAt).toLocaleString()}
475
- </DialogDescription>
476
- </div>
477
- <Button onClick={handleDownload} variant="outline" size="sm" className="gap-2">
478
- <Download className="h-4 w-4" />
479
- Download
480
- </Button>
481
- </DialogHeader>
482
- <div className="flex-1 overflow-auto">
483
- {isLoading ? (
484
- <div className="flex items-center justify-center h-full">
485
- <Loader2 className="h-8 w-8 animate-spin" />
486
- </div>
487
- ) : error ? (
488
- <div className="flex items-center justify-center h-full text-destructive">
489
- {error}
490
- </div>
491
- ) : content ? (
492
- fileDoc.type === "image" ? (
493
- <div className="flex items-center justify-center p-4">
494
- <img src={content} alt={fileDoc.name} className="max-w-full max-h-full object-contain" />
495
- </div>
496
- ) : fileDoc.type === "pdf" ? (
497
- <iframe src={content} className="h-full w-full rounded-md bg-muted/50" />
498
- ) : (
499
- <pre className="p-4 whitespace-pre-wrap font-mono text-sm">
500
- {content}
501
- </pre>
502
- )
503
- ) : null}
504
- </div>
505
- </DialogContent>
506
- </Dialog>
507
- );
508
- });
509
- FilePreviewDialog.displayName = 'FilePreviewDialog';
510
-
511
- const ModelSelector = React.memo(({
512
- selectedModel,
513
- selectedModelName,
514
- enabledChatModels,
515
- onModelChange
516
- }: {
517
- selectedModel: string;
518
- selectedModelName: string;
519
- enabledChatModels: string[] | undefined;
520
- onModelChange: (model: string) => void;
521
- }) => (
522
- <DropdownMenu>
523
- <DropdownMenuTrigger asChild>
524
- <Button
525
- variant="ghost"
526
- className="w-[200px] h-8 p-1 justify-start font-normal"
527
- >
528
- {selectedModelName}
529
- </Button>
530
- </DropdownMenuTrigger>
531
- <DropdownMenuContent className="w-[400px]">
532
- {CHAT_MODELS
533
- .filter((model) => enabledChatModels?.includes(model.model))
534
- .map((model) => (
535
- <TooltipProvider key={model.model}>
536
- <Tooltip>
537
- <TooltipTrigger asChild>
538
- <DropdownMenuItem
539
- className="p-4"
540
- onSelect={() => onModelChange(model.model)}
541
- >
542
- <div className="flex items-center gap-2 w-full">
543
- {model.model === selectedModel && (
544
- <Check className="h-4 w-4 shrink-0" />
545
- )}
546
- <span className="flex-grow">{model.name}</span>
547
- <Info className="h-4 w-4 text-muted-foreground shrink-0" />
548
- </div>
549
- </DropdownMenuItem>
550
- </TooltipTrigger>
551
- <TooltipContent side="right" align="start" className="max-w-[300px]">
552
- <p>{model.description}</p>
553
- {model.modalities && model.modalities.length > 0 && (
554
- <div className="flex gap-1 flex-wrap mt-1">
555
- {model.modalities.map((modality) => (
556
- <Badge key={modality} variant="secondary" className="capitalize text-xs">
557
- {modality.toLowerCase()}
558
- </Badge>
559
- ))}
560
- </div>
561
- )}
562
- </TooltipContent>
563
- </Tooltip>
564
- </TooltipProvider>
565
- ))}
566
- </DropdownMenuContent>
567
- </DropdownMenu>
568
- ));
569
- ModelSelector.displayName = 'ModelSelector';
570
-
571
- const AttachmentDropdown = React.memo(({
572
- isUrlInputOpen,
573
- setIsUrlInputOpen,
574
- urlInput,
575
- setUrlInput,
576
- handleAttachmentFileUpload,
577
- handleAttachmentUrlUpload
578
- }: {
579
- isUrlInputOpen: boolean;
580
- setIsUrlInputOpen: (open: boolean) => void;
581
- urlInput: string;
582
- setUrlInput: (url: string) => void;
583
- handleAttachmentFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
584
- handleAttachmentUrlUpload: () => void;
585
- }) => (
586
- <DropdownMenu>
587
- <DropdownMenuTrigger asChild>
588
- <Button
589
- variant="ghost"
590
- size="icon"
591
- className="h-8 w-8"
592
- >
593
- <Paperclip className="h-4 w-4" />
594
- </Button>
595
- </DropdownMenuTrigger>
596
- <DropdownMenuContent align="start" className="w-[300px]">
597
- <DropdownMenuItem
598
- onSelect={() => {
599
- const input = document.createElement("input");
600
- input.type = "file";
601
- input.multiple = true;
602
- input.accept = ".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.gif,.mp3,.mp4,.wav,.ogg";
603
- input.onchange = (e) => handleAttachmentFileUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
604
- input.click();
605
- }}
606
- >
607
- <Upload className="h-4 w-4 mr-2" />
608
- Upload Files
609
- </DropdownMenuItem>
610
- <DropdownMenuSeparator />
611
- <DropdownMenuItem onSelect={(e) => {
612
- e.preventDefault();
613
- setIsUrlInputOpen(true);
614
- }}>
615
- <Link2 className="h-4 w-4 mr-2" />
616
- Add URLs
617
- </DropdownMenuItem>
618
- {isUrlInputOpen && (
619
- <div className="p-2 flex gap-2">
620
- <Input
621
- value={urlInput}
622
- onChange={(e) => setUrlInput(e.target.value)}
623
- placeholder="Enter URLs (comma-separated)"
624
- className="flex-1"
625
- />
626
- <Button size="sm" onClick={handleAttachmentUrlUpload}>
627
- Add
628
- </Button>
629
- </div>
630
- )}
631
- </DropdownMenuContent>
632
- </DropdownMenu>
633
- ));
634
- AttachmentDropdown.displayName = 'AttachmentDropdown';
635
-
636
- // Main Component
637
- export function ChatPage2() {
638
- const { id } = useParams();
639
- const navigate = useNavigate();
640
- const queryClient = useQueryClient();
641
- const parentRef = React.useRef<HTMLDivElement>(null);
642
-
643
- // Services
644
- const chatManager = React.useMemo(() => new ChatManager(), []);
645
- const documentManager = React.useMemo(() => DocumentManager.getInstance(), []);
646
-
647
- // Queries
648
- const { data: chatSession, isLoading: isLoadingSession } = useChatSession(id);
649
- const { data: config, isLoading: isLoadingConfig } = useConfig();
650
-
651
- // Computed values
652
- const selectedModelName = React.useMemo(() => (
653
- CHAT_MODELS.find(model => model.model === chatSession?.model)?.name || "Select a model"
654
- ), [chatSession?.model]);
655
-
656
- // Local State
657
- const [state, setState] = React.useState<ChatState>({
658
- input: '',
659
- attachments: [],
660
- isUrlInputOpen: false,
661
- urlInput: '',
662
- previewDocument: null,
663
- isGenerating: false,
664
- streamingHumanMessage: null,
665
- streamingAIMessageChunks: [],
666
- editingMessageIndex: null,
667
- });
668
-
669
- // Mutations
670
- const sendMessageMutation = useMutation({
671
- mutationFn: async ({ chatId, input, attachments }: {
672
- chatId: string;
673
- input: string;
674
- attachments: IDocument[];
675
- }) => {
676
- setState(prev => ({
677
- ...prev,
678
- isGenerating: true,
679
- streamingHumanMessage: new HumanMessage(input),
680
- streamingAIMessageChunks: [],
681
- }));
682
-
683
- try {
684
- const messageIterator = chatManager.chat(chatId, input, attachments);
685
- for await (const event of messageIterator) {
686
- if (event.type === 'stream') {
687
- setState(prev => ({
688
- ...prev,
689
- streamingAIMessageChunks: [...prev.streamingAIMessageChunks, event.content],
690
- }));
691
- }
692
- }
693
- } finally {
694
- setState(prev => ({
695
- ...prev,
696
- isGenerating: false,
697
- streamingHumanMessage: null,
698
- streamingAIMessageChunks: [],
699
- }));
700
- queryClient.invalidateQueries({ queryKey: chatKeys.session(chatId) });
701
- }
702
- },
703
- });
704
-
705
- const regenerateMessageMutation = useMutation({
706
- mutationFn: async (messageIndex: number) => {
707
- if (!id || !chatSession) return;
708
-
709
- const messages = chatSession.messages;
710
- if (messages.length <= messageIndex) return;
711
-
712
- const message = messages[messageIndex];
713
- const content = message.data.content;
714
-
715
- // Remove messages after the current message
716
- const newMessages = messages.slice(0, messageIndex);
717
-
718
- const chatHistoryDB = new ChatHistoryDB();
719
- await chatHistoryDB.sessions.update(id, {
720
- ...chatSession,
721
- messages: newMessages,
722
- updatedAt: Date.now()
723
- });
724
-
725
- await sendMessageMutation.mutateAsync({
726
- chatId: id,
727
- input: content,
728
- attachments: [],
729
- });
730
- },
731
- });
732
-
733
- const editMessageMutation = useMutation({
734
- mutationFn: async ({ content }: { content: string }) => {
735
- if (!id || !chatSession || state.editingMessageIndex === null) return;
736
-
737
- // Update the message directly in the database
738
- const updatedMessages = [...chatSession.messages];
739
- updatedMessages[state.editingMessageIndex] = {
740
- ...updatedMessages[state.editingMessageIndex],
741
- data: {
742
- ...updatedMessages[state.editingMessageIndex].data,
743
- content
744
- }
745
- };
746
- // Remove messages after the edited message
747
- const newMessages = updatedMessages.slice(0, state.editingMessageIndex + 1);
748
-
749
- const chatHistoryDB = new ChatHistoryDB();
750
- await chatHistoryDB.sessions.update(id, {
751
- ...chatSession,
752
- messages: newMessages,
753
- updatedAt: Date.now()
754
- });
755
-
756
- setState(prev => ({
757
- ...prev,
758
- editingMessageIndex: null,
759
- }));
760
-
761
- await sendMessageMutation.mutateAsync({
762
- chatId: id,
763
- input: content,
764
- attachments: [],
765
- });
766
- },
767
- });
768
-
769
- // Handlers
770
- const handleSendMessage = React.useCallback(async () => {
771
- if (!state.input.trim() || state.isGenerating) return;
772
-
773
- let chatId = id;
774
- if (id === 'new') {
775
- chatId = crypto.randomUUID();
776
- new DexieChatMemory(chatId);
777
- navigate(`/chat/${chatId}`, { replace: true });
778
- }
779
-
780
- if (!chatId) return;
781
-
782
- setState(prev => ({
783
- ...prev,
784
- input: '',
785
- attachments: [],
786
- }));
787
-
788
- await sendMessageMutation.mutateAsync({
789
- chatId,
790
- input: state.input,
791
- attachments: state.attachments,
792
- });
793
- }, [id, state.input, state.attachments, state.isGenerating, navigate, sendMessageMutation]);
794
-
795
- const handleModelChange = React.useCallback(async (model: string) => {
796
- if (!id || !chatSession) return;
797
-
798
- try {
799
- const chatHistoryDB = new ChatHistoryDB();
800
- await chatHistoryDB.sessions.update(id, {
801
- ...chatSession,
802
- model,
803
- updatedAt: Date.now()
804
- });
805
- queryClient.invalidateQueries({ queryKey: chatKeys.session(id) });
806
- } catch (error) {
807
- console.error('Failed to update model:', error);
808
- toast.error('Failed to update model');
809
- }
810
- }, [id, chatSession, queryClient]);
811
-
812
- const handleAttachmentFileUpload = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
813
- const files = event.target.files;
814
- if (!files) return;
815
-
816
- try {
817
- const newDocs = await Promise.all(
818
- Array.from(files).map(file => documentManager.uploadDocument(file))
819
- );
820
- setState(prev => ({
821
- ...prev,
822
- attachments: [...prev.attachments, ...newDocs]
823
- }));
824
- } catch (error) {
825
- console.error(error);
826
- toast.error("Failed to upload files");
827
- }
828
- }, [documentManager]);
829
-
830
- const handleAttachmentUrlUpload = React.useCallback(async () => {
831
- if (!state.urlInput.trim()) return;
832
-
833
- try {
834
- const urls = state.urlInput.split(",").map(url => url.trim());
835
- const newDocs = await Promise.all(
836
- urls.map(url => documentManager.uploadUrl(url))
837
- );
838
- setState(prev => ({
839
- ...prev,
840
- attachments: [...prev.attachments, ...newDocs],
841
- urlInput: '',
842
- isUrlInputOpen: false,
843
- }));
844
- } catch (error) {
845
- console.error(error);
846
- toast.error("Failed to upload URLs");
847
- }
848
- }, [state.urlInput, documentManager]);
849
-
850
- const handleAttachmentRemove = React.useCallback((docId: string) => {
851
- setState(prev => ({
852
- ...prev,
853
- attachments: prev.attachments.filter(doc => doc.id !== docId)
854
- }));
855
- }, []);
856
-
857
- // Loading State
858
- if (isLoadingSession || isLoadingConfig) {
859
- return (
860
- <div className="flex items-center justify-center h-screen">
861
- <Loader2 className="h-8 w-8 animate-spin" />
862
- </div>
863
- );
864
- }
865
-
866
- // Error State
867
- if (!config) {
868
- return (
869
- <div className="flex items-center justify-center h-screen">
870
- <div className="text-center space-y-2">
871
- <p className="text-destructive">Failed to load configuration</p>
872
- <Button onClick={() => queryClient.invalidateQueries({ queryKey: chatKeys.config })}>
873
- Retry
874
- </Button>
875
- </div>
876
- </div>
877
- );
878
- }
879
-
880
- return (
881
- <div className="flex flex-col h-screen p-2">
882
- <div ref={parentRef} className="flex-1">
883
- <VirtualizedMessages
884
- messages={chatSession?.messages.map(mapStoredMessageToChatMessage) || []}
885
- streamingMessages={{
886
- human: state.streamingHumanMessage,
887
- ai: state.streamingAIMessageChunks,
888
- }}
889
- parentRef={parentRef}
890
- onEditMessage={(index) => setState(prev => ({ ...prev, editingMessageIndex: index }))}
891
- onRegenerateMessage={(index) => regenerateMessageMutation.mutate(index)}
892
- editingMessageIndex={state.editingMessageIndex}
893
- onSaveEdit={(content) => editMessageMutation.mutate({ content })}
894
- onCancelEdit={() => setState(prev => ({ ...prev, editingMessageIndex: null }))}
895
- />
896
- </div>
897
-
898
- {/* Chat Input */}
899
- <div className="flex flex-col w-1/2 mx-auto bg-muted rounded-md p-1">
900
- {state.attachments.length > 0 && (
901
- <DocumentBadgesScrollArea
902
- documents={state.attachments}
903
- onPreview={(doc) => setState(prev => ({ ...prev, previewDocument: doc }))}
904
- onRemove={handleAttachmentRemove}
905
- maxHeight="100px"
906
- />
907
- )}
908
- <AutosizeTextarea
909
- value={state.input}
910
- onChange={(e) => setState(prev => ({ ...prev, input: e.target.value }))}
911
- className="bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 resize-none p-0"
912
- maxHeight={300}
913
- minHeight={50}
914
- onKeyDown={(e) => {
915
- if (e.key === 'Enter' && !e.shiftKey) {
916
- e.preventDefault();
917
- handleSendMessage();
918
- }
919
- }}
920
- />
921
-
922
- {/* Input Actions */}
923
- <div className="flex justify-between items-center mt-2">
924
- <div className="flex gap-2">
925
- <ModelSelector
926
- selectedModel={chatSession?.model || ''}
927
- selectedModelName={selectedModelName}
928
- enabledChatModels={config?.enabled_chat_models}
929
- onModelChange={handleModelChange}
930
- />
931
- <AttachmentDropdown
932
- isUrlInputOpen={state.isUrlInputOpen}
933
- setIsUrlInputOpen={(open) => setState(prev => ({ ...prev, isUrlInputOpen: open }))}
934
- urlInput={state.urlInput}
935
- setUrlInput={(url) => setState(prev => ({ ...prev, urlInput: url }))}
936
- handleAttachmentFileUpload={handleAttachmentFileUpload}
937
- handleAttachmentUrlUpload={handleAttachmentUrlUpload}
938
- />
939
- </div>
940
- <Button
941
- variant="default"
942
- size="icon"
943
- className="rounded-full"
944
- onClick={handleSendMessage}
945
- disabled={state.isGenerating}
946
- >
947
- {state.isGenerating ? (
948
- <Loader2 className="h-4 w-4 animate-spin" />
949
- ) : (
950
- <Send className="h-4 w-4" />
951
- )}
952
- </Button>
953
- </div>
954
- </div>
955
-
956
- <FilePreviewDialog
957
- document={state.previewDocument}
958
- onClose={() => setState(prev => ({ ...prev, previewDocument: null }))}
959
- />
960
- </div>
961
- );
962
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/pages/chat/types.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseMessage, HumanMessage, AIMessageChunk, StoredMessage } from "@langchain/core/messages";
2
+ import { IDocument } from "@/lib/document/types";
3
+ import { DocumentManager } from "@/lib/document/manager";
4
+
5
+ export interface MessageProps {
6
+ message: BaseMessage;
7
+ documentManager?: DocumentManager;
8
+ setPreviewDocument?: (document: IDocument | null) => void;
9
+ previewDocument?: IDocument | null;
10
+ }
11
+
12
+ export interface MessagesProps {
13
+ messages: BaseMessage[] | undefined;
14
+ streamingHumanMessage: HumanMessage | null;
15
+ streamingAIMessageChunks: AIMessageChunk[];
16
+ setPreviewDocument: (document: IDocument | null) => void;
17
+ onEditMessage: (index: number) => void;
18
+ onRegenerateMessage: (index: number) => void;
19
+ editingMessageIndex: number | null;
20
+ onSaveEdit: (content: string) => void;
21
+ onCancelEdit: () => void;
22
+ }
23
+
24
+ export interface InputProps {
25
+ input: string;
26
+ selectedModel: string;
27
+ attachments: IDocument[];
28
+ enabledChatModels: string[] | undefined;
29
+ isUrlInputOpen: boolean;
30
+ urlInput: string;
31
+ selectedModelName: string;
32
+ isGenerating: boolean;
33
+ onInputChange: (value: string) => void;
34
+ onModelChange: (model: string) => void;
35
+ onSendMessage: () => void;
36
+ setPreviewDocument: (doc: IDocument | null) => void;
37
+ setIsUrlInputOpen: (open: boolean) => void;
38
+ setUrlInput: (url: string) => void;
39
+ handleAttachmentFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
40
+ handleAttachmentUrlUpload: () => void;
41
+ handleAttachmentRemove: (docId: string) => void;
42
+ }
43
+
44
+ export interface FilePreviewDialogProps {
45
+ document: IDocument | null;
46
+ onClose: () => void;
47
+ }
48
+
49
+ export interface Config {
50
+ default_chat_model: string;
51
+ enabled_chat_models: string[];
52
+ }
53
+
54
+ export interface ChatSession {
55
+ model: string;
56
+ updatedAt: number;
57
+ messages: StoredMessage[];
58
+ }