Skip to content

Commit 65e03c0

Browse files
committed
feat: Chat sharing
1 parent 9229b9e commit 65e03c0

12 files changed

Lines changed: 436 additions & 40 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Attachment" ADD COLUMN "filetype" TEXT;

src/actions/settings.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,45 @@ export async function deleteChat(id: string) {
5252
where: { id },
5353
});
5454
}
55+
56+
export const shareChat = async (id: string): Promise<void> => {
57+
const { claims } = await getLogtoContext(logtoConfig);
58+
59+
if (!claims) {
60+
throw new Error("User not authenticated");
61+
}
62+
63+
const chat = await prisma.chat.findUnique({
64+
where: { id },
65+
});
66+
67+
if (!chat || chat.userId !== claims.sub) {
68+
throw new Error("Chat not found or access denied");
69+
}
70+
71+
await prisma.chat.update({
72+
where: { id },
73+
data: { public: true },
74+
});
75+
};
76+
77+
export const unshareChat = async (id: string): Promise<void> => {
78+
const { claims } = await getLogtoContext(logtoConfig);
79+
80+
if (!claims) {
81+
throw new Error("User not authenticated");
82+
}
83+
84+
const chat = await prisma.chat.findUnique({
85+
where: { id },
86+
});
87+
88+
if (!chat || chat.userId !== claims.sub) {
89+
throw new Error("Chat not found or access denied");
90+
}
91+
92+
await prisma.chat.update({
93+
where: { id },
94+
data: { public: false },
95+
});
96+
};

src/app/(chat)/chat/[id]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export default async function Home({
9393
model={modelInfo?.name || chat.model || "Unknown Model"}
9494
title={chat.name || "Untitled Chat"}
9595
id={id}
96+
shared={chat.public}
9697
/>
9798
<div className="space-y-4">
9899
<ChatContainer
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { getLogtoContext } from "@logto/next/server-actions";
2+
import { logtoConfig } from "@/lib/auth";
3+
import { redirect, notFound } from "next/navigation";
4+
import { prisma } from "@/lib/prisma";
5+
import Models from "@/consts/models.json";
6+
import * as React from "react";
7+
import { ChatContainer } from "@/components/chatContainer";
8+
import { ChatMeta } from "@/components/chatMeta";
9+
import Image from "next/image";
10+
import Link from "next/link";
11+
12+
import UserIcon from "@/assets/img/user.png";
13+
import Logo from "@/assets/img/mikan-vtube.svg";
14+
15+
const ModelInfoFromID: Record<string, { name: string; description: string }> =
16+
Object.entries(Models)
17+
.flatMap(([providerKey, provider]) =>
18+
provider.models.map(
19+
(model) =>
20+
[
21+
model.id,
22+
{ name: model.name, description: provider.description },
23+
] as [string, { name: string; description: string }],
24+
),
25+
)
26+
.reduce(
27+
(
28+
acc,
29+
[id, info]: [string, { name: string; description: string }],
30+
) => ({
31+
...acc,
32+
[id]: info,
33+
}),
34+
{},
35+
);
36+
37+
export async function generateMetadata({
38+
params,
39+
}: { params: Promise<{ id: string }> }) {
40+
const id = (await params).id;
41+
42+
const chat = await prisma.chat.findUnique({
43+
where: { id },
44+
include: {
45+
messages: {
46+
orderBy: { createdAt: "asc" },
47+
},
48+
},
49+
});
50+
51+
if (!chat || !chat.public) {
52+
return {
53+
title: "Chat not found",
54+
description: "The requested chat does not exist or is not public.",
55+
};
56+
}
57+
58+
const modelInfo = ModelInfoFromID[chat.model] || { name: "Unknown Model" };
59+
60+
return {
61+
title: `MD Chat - ${chat.name || "Untitled Chat"}`,
62+
description: `Chat using ${modelInfo.name}. Read it on MD Chat!`,
63+
};
64+
}
65+
66+
export default async function SharePage({
67+
params,
68+
}: { params: Promise<{ id: string }> }) {
69+
const { id } = await params;
70+
const { claims } = await getLogtoContext(logtoConfig);
71+
72+
const chat = await prisma.chat.findUnique({
73+
where: { id },
74+
include: {
75+
messages: {
76+
orderBy: { createdAt: "asc" },
77+
},
78+
},
79+
});
80+
81+
if (!chat) {
82+
return notFound();
83+
}
84+
85+
if (!chat.public) {
86+
return notFound();
87+
}
88+
89+
if (chat.userId == claims?.sub) {
90+
await redirect(`/chat/${id}`);
91+
}
92+
93+
const messages = await prisma.message.findMany({
94+
where: { chatId: chat.id },
95+
orderBy: { createdAt: "asc" },
96+
include: { attachments: true },
97+
});
98+
99+
const modelsArray = Object.entries(Models).flatMap(
100+
([providerKey, provider]) =>
101+
provider.models.map((model) => ({
102+
...model,
103+
provider: providerKey,
104+
providerName: provider.name,
105+
icon: provider.icon,
106+
})),
107+
);
108+
109+
const modelInfo = ModelInfoFromID[chat.model] || chat.model;
110+
111+
const formattedMessages = messages.map((message) => ({
112+
id: message.id,
113+
content: message.content,
114+
role: message.role as "user" | "assistant" | "system",
115+
createdAt: message.createdAt,
116+
experimental_attachments: message.attachments.map((attachment) => ({
117+
id: attachment.id,
118+
url: attachment.url,
119+
contentType: attachment.filetype || "unknown",
120+
})),
121+
}));
122+
123+
return (
124+
<main className="container mx-auto p-4">
125+
<ChatMeta
126+
createdAt={chat.createdAt.toISOString()}
127+
model={modelInfo?.name || chat.model || "Unknown Model"}
128+
title={chat.name || "Untitled Chat"}
129+
id={id}
130+
shared={chat.public}
131+
isPublic={true}
132+
/>
133+
<div className="space-y-4">
134+
<ChatContainer
135+
id={id}
136+
avatar={UserIcon.src}
137+
//@ts-ignore
138+
initialMessages={formattedMessages}
139+
model={chat.model}
140+
//@ts-ignore
141+
models={modelsArray}
142+
isPublic={true}
143+
/>
144+
</div>
145+
<div className="flex items-center w-full card bg-base-200 shadow-xl p-4 mt-8">
146+
<div className="flex flex-row justify-between items-center w-full">
147+
<div className="flex flex-row items-center">
148+
<p
149+
className={
150+
"text-base-content/70 text-sm mr-2 font-bold text-xl"
151+
}
152+
>
153+
Generated on
154+
</p>
155+
<Image
156+
src={Logo}
157+
alt="MikanDev Logo"
158+
width={200}
159+
height={200}
160+
className="rounded-lg w-1/6 h-auto"
161+
/>
162+
<p
163+
className={
164+
"text-base-content/70 text-sm ml-2 font-bold text-xl"
165+
}
166+
>
167+
Chat
168+
</p>
169+
</div>
170+
<Link href={`/chat`} className="">
171+
<button className={"btn btn-secondary"}>
172+
Try it now!
173+
</button>
174+
</Link>
175+
</div>
176+
</div>
177+
</main>
178+
);
179+
}

src/app/(public)/template.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
SidebarProvider,
3+
SidebarInset,
4+
SidebarTrigger,
5+
} from "@/components/animate-ui/radix/sidebar";
6+
import { ChatSidebar } from "@/components/sidebar";
7+
import { getLogtoContext } from "@logto/next/server-actions";
8+
import { logtoConfig } from "@/lib/auth";
9+
import { prisma } from "@/lib/prisma";
10+
import * as React from "react";
11+
12+
export default async function ChatLayout({
13+
children,
14+
}: {
15+
children: React.ReactNode;
16+
}) {
17+
const { claims } = await getLogtoContext(logtoConfig);
18+
const chats = await prisma.chat.findMany({
19+
where: { userId: claims?.sub || "" },
20+
});
21+
22+
const data = {
23+
user: {
24+
name: claims?.name || "...",
25+
id: claims?.sub || "...",
26+
avatar: claims?.picture || "/default-avatar.png",
27+
},
28+
chats: chats.map((chat) => ({
29+
name: chat.name || "Untitled Chat",
30+
id: chat.id,
31+
})),
32+
};
33+
34+
return (
35+
<SidebarProvider>
36+
<SidebarInset>{children}</SidebarInset>
37+
</SidebarProvider>
38+
);
39+
}

src/app/(settings)/settings/files/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export default async function Home() {
6969
</h2>
7070
<div className="grid gap-2 grid-cols-4">
7171
{attachmentsWithFileType.map((attachment) => (
72-
<div key={attachment.id} className="p-4 border rounded">
72+
<div
73+
key={attachment.id}
74+
className="card bg-base-200 shadow-xl items-center p-4"
75+
>
7376
{attachment.fileType === "jpg" ||
7477
attachment.fileType === "png" ||
7578
attachment.fileType === "jpeg" ? (
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// src/app/(public)/share/[id]/page.tsx
2+
import { prisma } from "@/lib/prisma";
3+
import { ChatPage } from "@/components/chat";
4+
import { notFound } from "next/navigation";
5+
import AIIcon from "@/assets/img/ai.png";
6+
7+
export default async function SharedChatPage({
8+
params,
9+
}: { params: { id: string } }) {
10+
const { id } = params;
11+
12+
// Fetch the chat and its messages
13+
const chat = await prisma.chat.findUnique({
14+
where: { id, public: true },
15+
include: { messages: { orderBy: { createdAt: "asc" } } },
16+
});
17+
18+
if (!chat) {
19+
return notFound();
20+
}
21+
22+
// Format messages to match the expected structure by ChatPage
23+
const formattedMessages = chat.messages.map((msg) => ({
24+
id: msg.id,
25+
role: msg.role as "user" | "assistant" | "system",
26+
content: msg.content,
27+
parts: [{ type: "text", text: msg.content }],
28+
attachment: msg.attachmentId
29+
? {
30+
url: msg.attachmentId,
31+
name: msg.attachmentName || undefined,
32+
contentType: msg.attachmentType || undefined,
33+
}
34+
: undefined,
35+
}));
36+
37+
return (
38+
<div className="container mx-auto flex flex-col h-screen">
39+
<div className="bg-base-200 p-4 flex items-center">
40+
<h1 className="text-2xl font-bold">
41+
{chat.name || "Shared Chat"}
42+
</h1>
43+
<span className="ml-2 badge badge-primary">Read Only</span>
44+
</div>
45+
46+
<div className="flex-grow overflow-hidden">
47+
<ChatPage
48+
id={id}
49+
msg={formattedMessages}
50+
avatar="/avatar-placeholder.png" // Default avatar for shared chats
51+
status="ready"
52+
/>
53+
</div>
54+
</div>
55+
);
56+
}

src/assets/img/user.png

27 KB
Loading

0 commit comments

Comments
 (0)