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 +5 -3
- src/lib/chat/manager.ts +13 -1
- src/pages/chat/components/AIMessage.tsx +130 -0
- src/pages/chat/components/AttachmentDropdown.tsx +73 -0
- src/pages/chat/components/DocumentBadge.tsx +42 -0
- src/pages/chat/components/DocumentBadgesScrollArea.tsx +39 -0
- src/pages/chat/components/FilePreviewDialog.tsx +123 -0
- src/pages/chat/components/HumanMessage.tsx +96 -0
- src/pages/chat/components/Input.tsx +87 -0
- src/pages/chat/components/Messages.tsx +112 -0
- src/pages/chat/components/ModelSelector.tsx +70 -0
- src/pages/chat/hooks.ts +74 -0
- src/pages/chat/page.tsx +44 -811
- src/pages/chat/page2.tsx +0 -962
- src/pages/chat/types.ts +58 -0
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 |
-
|
|
|
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"
|
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 {
|
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 {
|
39 |
-
import {
|
40 |
-
import
|
41 |
-
import {
|
42 |
-
import {
|
43 |
-
import
|
44 |
-
import
|
45 |
-
import
|
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 |
-
|
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 |
-
|
812 |
-
|
813 |
-
|
814 |
-
|
815 |
-
|
816 |
-
|
817 |
-
|
818 |
-
|
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,
|
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 |
-
|
873 |
-
|
874 |
-
|
875 |
-
updatedMessages[editingMessageIndex]
|
876 |
-
|
877 |
-
data
|
878 |
-
|
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 |
-
}
|
907 |
-
|
908 |
-
|
909 |
-
|
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
|
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 |
+
}
|