Skip to content

Commit 709d8cb

Browse files
committed
チャットがサーバーに保存されるようになった
1 parent f8fbe5f commit 709d8cb

File tree

9 files changed

+145
-99
lines changed

9 files changed

+145
-99
lines changed

app/[docs_id]/chatForm.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import { useState, FormEvent, useEffect } from "react";
4-
import { askAI } from "@/app/actions/chatActions";
54
import useSWR from "swr";
65
import {
76
getQuestionExample,
@@ -10,7 +9,8 @@ import {
109
import { getLanguageName } from "../pagesList";
1110
import { DynamicMarkdownSection } from "./pageContent";
1211
import { useEmbedContext } from "../terminal/embedContext";
13-
import { ChatMessage, useChatHistoryContext } from "./chatHistory";
12+
import { useChatHistoryContext } from "./chatHistory";
13+
import { askAI } from "@/actions/chatActions";
1414

1515
interface ChatFormProps {
1616
docs_id: string;
@@ -71,8 +71,6 @@ export function ChatForm({
7171
setIsLoading(true);
7272
setErrorMessage(null); // Clear previous error message
7373

74-
const userMessage: ChatMessage = { sender: "user", text: inputValue };
75-
7674
let userQuestion = inputValue;
7775
if (!userQuestion && exampleData) {
7876
// 質問が空欄なら、質問例を使用
@@ -83,19 +81,19 @@ export function ChatForm({
8381

8482
const result = await askAI({
8583
userQuestion,
84+
docsId: docs_id,
8685
documentContent,
8786
sectionContent,
8887
replOutputs,
8988
files,
9089
execResults,
9190
});
9291

93-
if (result.error) {
92+
if (result.error !== null) {
9493
setErrorMessage(result.error);
9594
console.log(result.error);
9695
} else {
97-
const aiMessage: ChatMessage = { sender: "ai", text: result.response };
98-
const chatId = addChat(result.targetSectionId, [userMessage, aiMessage]);
96+
addChat(result.chat);
9997
// TODO: chatIdが指す対象の回答にフォーカス
10098
setInputValue("");
10199
close();

app/[docs_id]/chatHistory.tsx

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { ChatWithMessages } from "@/lib/chatHistory";
34
import {
45
createContext,
56
ReactNode,
@@ -8,15 +9,10 @@ import {
89
useState,
910
} from "react";
1011

11-
export interface ChatMessage {
12-
sender: "user" | "ai" | "error";
13-
text: string;
14-
}
15-
1612
export interface IChatHistoryContext {
17-
chatHistories: Record<string, Record<string, ChatMessage[]>>;
18-
addChat: (sectionId: string, messages: ChatMessage[]) => string;
19-
updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void;
13+
chatHistories: ChatWithMessages[];
14+
addChat: (chat: ChatWithMessages) => void;
15+
// updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void;
2016
}
2117
const ChatHistoryContext = createContext<IChatHistoryContext | null>(null);
2218
export function useChatHistoryContext() {
@@ -29,65 +25,26 @@ export function useChatHistoryContext() {
2925
return context;
3026
}
3127

32-
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
33-
const [chatHistories, setChatHistories] = useState<
34-
Record<string, Record<string, ChatMessage[]>>
35-
>({});
28+
export function ChatHistoryProvider({
29+
children,
30+
initialChatHistories,
31+
}: {
32+
children: ReactNode;
33+
initialChatHistories: ChatWithMessages[];
34+
}) {
35+
const [chatHistories, setChatHistories] =
36+
useState<ChatWithMessages[]>(initialChatHistories);
3637
useEffect(() => {
37-
// Load chat histories from localStorage on mount
38-
const chatHistories: Record<string, Record<string, ChatMessage[]>> = {};
39-
for (let i = 0; i < localStorage.length; i++) {
40-
const key = localStorage.key(i);
41-
if (key && key.startsWith("chat/") && key.split("/").length === 3) {
42-
const savedHistory = localStorage.getItem(key);
43-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
44-
const [_, sectionId, chatId] = key.split("/");
45-
if (savedHistory) {
46-
if (!chatHistories[sectionId]) {
47-
chatHistories[sectionId] = {};
48-
}
49-
chatHistories[sectionId][chatId] = JSON.parse(savedHistory);
50-
}
51-
}
52-
}
53-
setChatHistories(chatHistories);
54-
}, []);
38+
setChatHistories(initialChatHistories);
39+
}, [initialChatHistories]);
5540

56-
const addChat = (sectionId: string, messages: ChatMessage[]): string => {
57-
const chatId = Date.now().toString();
58-
const newChatHistories = { ...chatHistories };
59-
if (!newChatHistories[sectionId]) {
60-
newChatHistories[sectionId] = {};
61-
}
62-
newChatHistories[sectionId][chatId] = messages;
63-
setChatHistories(newChatHistories);
64-
localStorage.setItem(
65-
`chat/${sectionId}/${chatId}`,
66-
JSON.stringify(messages)
67-
);
68-
return chatId;
69-
};
70-
const updateChat = (
71-
sectionId: string,
72-
chatId: string,
73-
message: ChatMessage
74-
) => {
75-
const newChatHistories = { ...chatHistories };
76-
if (newChatHistories[sectionId] && newChatHistories[sectionId][chatId]) {
77-
newChatHistories[sectionId][chatId] = [
78-
...newChatHistories[sectionId][chatId],
79-
message,
80-
];
81-
setChatHistories(newChatHistories);
82-
localStorage.setItem(
83-
`chat/${sectionId}/${chatId}`,
84-
JSON.stringify(newChatHistories[sectionId][chatId])
85-
);
86-
}
41+
const addChat = (chat: ChatWithMessages) => {
42+
// サーバー側で追加された新しいchatをクライアント側にも反映する
43+
setChatHistories([...chatHistories, chat]);
8744
};
8845

8946
return (
90-
<ChatHistoryContext.Provider value={{ chatHistories, addChat, updateChat }}>
47+
<ChatHistoryContext.Provider value={{ chatHistories, addChat }}>
9148
{children}
9249
</ChatHistoryContext.Provider>
9350
);

app/[docs_id]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MarkdownSection, splitMarkdown } from "./splitMarkdown";
66
import pyodideLock from "pyodide/pyodide-lock.json";
77
import { PageContent } from "./pageContent";
88
import { ChatHistoryProvider } from "./chatHistory";
9+
import { getChat } from "@/lib/chatHistory";
910

1011
export default async function Page({
1112
params,
@@ -44,8 +45,10 @@ export default async function Page({
4445

4546
const splitMdContent: MarkdownSection[] = splitMarkdown(mdContent);
4647

48+
const initialChatHistories = await getChat(docs_id);
49+
4750
return (
48-
<ChatHistoryProvider>
51+
<ChatHistoryProvider initialChatHistories={initialChatHistories}>
4952
<PageContent
5053
documentContent={mdContent}
5154
splitMdContent={splitMdContent}

app/[docs_id]/pageContent.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ export function PageContent(props: PageContentProps) {
9494
</div>
9595
<div key={`${index}-chat`}>
9696
{/* 右側に表示するチャット履歴欄 */}
97-
{Object.entries(chatHistories[section.sectionId] ?? {}).map(
98-
([chatId, messages]) => (
97+
{chatHistories.filter((c) => c.sectionId === section.sectionId).map(
98+
({chatId, messages}) => (
9999
<div
100100
key={chatId}
101101
className="max-w-xs mb-2 p-2 text-sm border border-base-content/10 rounded-sm shadow-sm bg-base-100"
@@ -104,20 +104,20 @@ export function PageContent(props: PageContentProps) {
104104
{messages.map((msg, index) => (
105105
<div
106106
key={index}
107-
className={`chat ${msg.sender === "user" ? "chat-end" : "chat-start"}`}
107+
className={`chat ${msg.role === "user" ? "chat-end" : "chat-start"}`}
108108
>
109109
<div
110110
className={clsx(
111111
"chat-bubble p-1!",
112-
msg.sender === "user" &&
112+
msg.role === "user" &&
113113
"bg-primary text-primary-content",
114-
msg.sender === "ai" &&
114+
msg.role === "ai" &&
115115
"bg-secondary-content dark:bg-neutral text-black dark:text-white",
116-
msg.sender === "error" && "chat-bubble-error"
116+
msg.role === "error" && "chat-bubble-error"
117117
)}
118118
style={{ maxWidth: "100%", wordBreak: "break-word" }}
119119
>
120-
<StyledMarkdown content={msg.text} />
120+
<StyledMarkdown content={msg.content} />
121121
</div>
122122
</div>
123123
))}

app/actions/chatActions.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,29 @@
44
import { generateContent } from "./gemini";
55
import { DynamicMarkdownSection } from "../[docs_id]/pageContent";
66
import { ReplCommand, ReplOutput } from "../terminal/repl";
7+
import { addChat, ChatWithMessages } from "@/lib/chatHistory";
78

8-
interface FormState {
9-
response: string;
10-
error: string | null;
11-
targetSectionId: string;
12-
}
9+
type ChatResult =
10+
| {
11+
error: string;
12+
}
13+
| {
14+
error: null;
15+
// サーバー側でデータベースに新しく追加されたチャットデータ
16+
chat: ChatWithMessages;
17+
};
1318

1419
type ChatParams = {
1520
userQuestion: string;
21+
docsId: string;
1622
documentContent: string;
1723
sectionContent: DynamicMarkdownSection[];
1824
replOutputs: Record<string, ReplCommand[]>;
1925
files: Record<string, string>;
2026
execResults: Record<string, ReplOutput[]>;
2127
};
2228

23-
export async function askAI(params: ChatParams): Promise<FormState> {
29+
export async function askAI(params: ChatParams): Promise<ChatResult> {
2430
// const parseResult = ChatSchema.safeParse(params);
2531

2632
// if (!parseResult.success) {
@@ -141,25 +147,21 @@ export async function askAI(params: ChatParams): Promise<FormState> {
141147
if (!text) {
142148
throw new Error("AIからの応答が空でした");
143149
}
150+
// TODO: どのセクションへの回答にするかをAIに決めさせる
151+
const targetSectionId =
152+
sectionContent.find((s) => s.inView)?.sectionId || "";
153+
const newChat = await addChat(params.docsId, targetSectionId, [
154+
{ role: "user", content: userQuestion },
155+
{ role: "ai", content: text },
156+
]);
144157
return {
145-
response: text,
146158
error: null,
147-
// TODO: どのセクションへの回答にするかをAIに決めさせる
148-
targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "",
159+
chat: newChat,
149160
};
150161
} catch (error: unknown) {
151162
console.error("Error calling Generative AI:", error);
152-
if (error instanceof Error) {
153-
return {
154-
response: "",
155-
error: `AIへのリクエスト中にエラーが発生しました: ${error.message}`,
156-
targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "",
157-
};
158-
}
159163
return {
160-
response: "",
161-
error: "予期せぬエラーが発生しました。",
162-
targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "",
164+
error: String(error),
163165
};
164166
}
165167
}

app/lib/auth.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { betterAuth } from "better-auth";
22
import { prismaAdapter } from "better-auth/adapters/prisma";
3-
import { PrismaClient } from "../generated/prisma/client";
43
import { getCloudflareContext } from "@opennextjs/cloudflare";
54
import { anonymous } from "better-auth/plugins";
6-
7-
const prisma = new PrismaClient();
5+
import prisma from "./prisma";
86

97
// eslint-disable-next-line @typescript-eslint/no-explicit-any
108
let cloudflareEnv: any;

app/lib/chatHistory.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { headers } from "next/headers";
2+
import { auth } from "./auth";
3+
import prisma from "./prisma";
4+
5+
export interface CreateChatMessage {
6+
role: "user" | "ai" | "error";
7+
content: string;
8+
}
9+
10+
export async function addChat(
11+
docsId: string,
12+
sectionId: string,
13+
messages: CreateChatMessage[]
14+
) {
15+
const session = await auth.api.getSession({ headers: await headers() });
16+
if (!session) {
17+
throw new Error("Not authenticated");
18+
}
19+
20+
return await prisma.chat.create({
21+
data: {
22+
userId: session.user.id,
23+
docsId,
24+
sectionId,
25+
messages: {
26+
createMany: {
27+
data: messages,
28+
},
29+
},
30+
},
31+
include: {
32+
messages: true,
33+
},
34+
});
35+
}
36+
37+
export type ChatWithMessages = Awaited<ReturnType<typeof addChat>>;
38+
39+
export async function getChat(docsId: string) {
40+
const session = await auth.api.getSession({ headers: await headers() });
41+
if (!session) {
42+
return [];
43+
}
44+
45+
return await prisma.chat.findMany({
46+
where: {
47+
userId: session.user.id,
48+
docsId,
49+
},
50+
include: {
51+
messages: {
52+
orderBy: {
53+
id: "asc",
54+
},
55+
},
56+
},
57+
orderBy: {
58+
chatId: "asc",
59+
},
60+
});
61+
}

app/lib/prisma.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { PrismaClient } from "../generated/prisma/client";
2+
3+
const prisma = new PrismaClient();
4+
export default prisma;
5+

prisma/schema.prisma

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ model User {
2727
2828
isAnonymous Boolean?
2929
30+
Chat Chat[]
31+
3032
@@unique([email])
3133
@@map("user")
3234
}
@@ -75,3 +77,23 @@ model Verification {
7577
7678
@@map("verification")
7779
}
80+
81+
// ここまでbetter-authが使う
82+
83+
model Chat {
84+
chatId Int @id @default(autoincrement())
85+
userId String
86+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
87+
docsId String
88+
sectionId String
89+
90+
messages Message[]
91+
}
92+
93+
model Message {
94+
id Int @id @default(autoincrement())
95+
chatId Int
96+
chat Chat @relation(fields: [chatId], references: [chatId], onDelete: Cascade)
97+
role String
98+
content String
99+
}

0 commit comments

Comments
 (0)