diff --git a/.gitignore b/.gitignore index efb8a61..485c203 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/app/generated/prisma diff --git a/README.md b/README.md index b4246e8..d58b8a2 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,31 @@ https://my-code.utcode.net npm ci ``` -ルートディレクトリに .env.local という名前のファイルを作成し、Gemini APIキーを設定してください +ルートディレクトリに .env.local という名前のファイルを作成し、以下の内容を記述 ```dotenv -API_KEY="XXXXXXXX" +API_KEY=GeminiAPIキー +BETTER_AUTH_URL=http://localhost:3000 ``` +prismaの開発環境を起動 +(.env にDATABASE_URLが自動的に追加される) +```bash +npx prisma dev +``` +別ターミナルで +```bash +npx prisma db push +``` + +### 本番環境の場合 + +上記の環境変数以外に、 +* BETTER_AUTH_SECRET に任意の文字列 +* DATABASE_URL に本番用のPostgreSQLデータベースURL +* GOOGLE_CLIENT_IDとGOOGLE_CLIENT_SECRETにGoogle OAuthのクライアントIDとシークレット https://www.better-auth.com/docs/authentication/google +* GITHUB_CLIENT_IDとGITHUB_CLIENT_SECRETにGitHub OAuthのクライアントIDとシークレット https://www.better-auth.com/docs/authentication/github + + ## 開発環境 ```bash diff --git a/app/[docs_id]/chatForm.tsx b/app/[docs_id]/chatForm.tsx index 932be11..381e014 100644 --- a/app/[docs_id]/chatForm.tsx +++ b/app/[docs_id]/chatForm.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, FormEvent, useEffect } from "react"; -import { askAI } from "@/app/actions/chatActions"; import useSWR from "swr"; import { getQuestionExample, @@ -10,7 +9,8 @@ import { import { getLanguageName } from "../pagesList"; import { DynamicMarkdownSection } from "./pageContent"; import { useEmbedContext } from "../terminal/embedContext"; -import { ChatMessage, useChatHistoryContext } from "./chatHistory"; +import { useChatHistoryContext } from "./chatHistory"; +import { askAI } from "@/actions/chatActions"; interface ChatFormProps { docs_id: string; @@ -71,8 +71,6 @@ export function ChatForm({ setIsLoading(true); setErrorMessage(null); // Clear previous error message - const userMessage: ChatMessage = { sender: "user", text: inputValue }; - let userQuestion = inputValue; if (!userQuestion && exampleData) { // 質問が空欄なら、質問例を使用 @@ -83,6 +81,7 @@ export function ChatForm({ const result = await askAI({ userQuestion, + docsId: docs_id, documentContent, sectionContent, replOutputs, @@ -90,12 +89,11 @@ export function ChatForm({ execResults, }); - if (result.error) { + if (result.error !== null) { setErrorMessage(result.error); console.log(result.error); } else { - const aiMessage: ChatMessage = { sender: "ai", text: result.response }; - const chatId = addChat(result.targetSectionId, [userMessage, aiMessage]); + addChat(result.chat); // TODO: chatIdが指す対象の回答にフォーカス setInputValue(""); close(); diff --git a/app/[docs_id]/chatHistory.tsx b/app/[docs_id]/chatHistory.tsx index 65d95ce..fc92e5d 100644 --- a/app/[docs_id]/chatHistory.tsx +++ b/app/[docs_id]/chatHistory.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChatWithMessages } from "@/lib/chatHistory"; import { createContext, ReactNode, @@ -8,15 +9,10 @@ import { useState, } from "react"; -export interface ChatMessage { - sender: "user" | "ai" | "error"; - text: string; -} - export interface IChatHistoryContext { - chatHistories: Record>; - addChat: (sectionId: string, messages: ChatMessage[]) => string; - updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void; + chatHistories: ChatWithMessages[]; + addChat: (chat: ChatWithMessages) => void; + // updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void; } const ChatHistoryContext = createContext(null); export function useChatHistoryContext() { @@ -29,65 +25,26 @@ export function useChatHistoryContext() { return context; } -export function ChatHistoryProvider({ children }: { children: ReactNode }) { - const [chatHistories, setChatHistories] = useState< - Record> - >({}); +export function ChatHistoryProvider({ + children, + initialChatHistories, +}: { + children: ReactNode; + initialChatHistories: ChatWithMessages[]; +}) { + const [chatHistories, setChatHistories] = + useState(initialChatHistories); useEffect(() => { - // Load chat histories from localStorage on mount - const chatHistories: Record> = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith("chat/") && key.split("/").length === 3) { - const savedHistory = localStorage.getItem(key); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, sectionId, chatId] = key.split("/"); - if (savedHistory) { - if (!chatHistories[sectionId]) { - chatHistories[sectionId] = {}; - } - chatHistories[sectionId][chatId] = JSON.parse(savedHistory); - } - } - } - setChatHistories(chatHistories); - }, []); + setChatHistories(initialChatHistories); + }, [initialChatHistories]); - const addChat = (sectionId: string, messages: ChatMessage[]): string => { - const chatId = Date.now().toString(); - const newChatHistories = { ...chatHistories }; - if (!newChatHistories[sectionId]) { - newChatHistories[sectionId] = {}; - } - newChatHistories[sectionId][chatId] = messages; - setChatHistories(newChatHistories); - localStorage.setItem( - `chat/${sectionId}/${chatId}`, - JSON.stringify(messages) - ); - return chatId; - }; - const updateChat = ( - sectionId: string, - chatId: string, - message: ChatMessage - ) => { - const newChatHistories = { ...chatHistories }; - if (newChatHistories[sectionId] && newChatHistories[sectionId][chatId]) { - newChatHistories[sectionId][chatId] = [ - ...newChatHistories[sectionId][chatId], - message, - ]; - setChatHistories(newChatHistories); - localStorage.setItem( - `chat/${sectionId}/${chatId}`, - JSON.stringify(newChatHistories[sectionId][chatId]) - ); - } + const addChat = (chat: ChatWithMessages) => { + // サーバー側で追加された新しいchatをクライアント側にも反映する + setChatHistories([...chatHistories, chat]); }; return ( - + {children} ); diff --git a/app/[docs_id]/page.tsx b/app/[docs_id]/page.tsx index 9c89033..7cf15dc 100644 --- a/app/[docs_id]/page.tsx +++ b/app/[docs_id]/page.tsx @@ -6,6 +6,7 @@ import { MarkdownSection, splitMarkdown } from "./splitMarkdown"; import pyodideLock from "pyodide/pyodide-lock.json"; import { PageContent } from "./pageContent"; import { ChatHistoryProvider } from "./chatHistory"; +import { getChat } from "@/lib/chatHistory"; export default async function Page({ params, @@ -44,8 +45,10 @@ export default async function Page({ const splitMdContent: MarkdownSection[] = splitMarkdown(mdContent); + const initialChatHistories = await getChat(docs_id); + return ( - +
{/* 右側に表示するチャット履歴欄 */} - {Object.entries(chatHistories[section.sectionId] ?? {}).map( - ([chatId, messages]) => ( + {chatHistories.filter((c) => c.sectionId === section.sectionId).map( + ({chatId, messages}) => (
(
- +
))} diff --git a/app/accountMenu.tsx b/app/accountMenu.tsx new file mode 100644 index 0000000..c5d0f4c --- /dev/null +++ b/app/accountMenu.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { authClient } from "@/lib/auth-client"; +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +export function AutoAnonymousLogin() { + const { data: session, isPending } = authClient.useSession(); + useEffect(() => { + if (!isPending && !session) { + authClient.signIn.anonymous(); + } + }, [isPending, session]); + + return null; +} + +export function AccountMenu() { + const { data: session, isPending } = authClient.useSession(); + const pathname = usePathname(); + + const signout = () => { + if ( + window.confirm( + "ログアウトしますか?\nチャット履歴はこの端末上で見られなくなりますが、再度ログインすることでアクセスできます。" + ) + ) { + authClient.signOut({ + fetchOptions: { + onSuccess: () => window.location.reload(), + }, + }); + } + }; + const signoutFromAnonymous = () => { + if (window.confirm("チャット履歴は削除され、アクセスできなくなります。")) { + authClient.signOut({ + fetchOptions: { + onSuccess: () => window.location.reload(), + }, + }); + } + }; + + if (isPending) { + return
; + } + + if (session && !session.user.isAnonymous) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
    +
  • + ログインすると、チャット履歴を保存し別のデバイスからもアクセスできるようになります。 +
  • +
  • + +
  • +
  • + +
  • + {session?.user && ( + <> +
    +
  • + +
  • + + )} +
+
+ ); +} diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts index 7adf58e..87dfa18 100644 --- a/app/actions/chatActions.ts +++ b/app/actions/chatActions.ts @@ -4,15 +4,21 @@ import { generateContent } from "./gemini"; import { DynamicMarkdownSection } from "../[docs_id]/pageContent"; import { ReplCommand, ReplOutput } from "../terminal/repl"; +import { addChat, ChatWithMessages } from "@/lib/chatHistory"; -interface FormState { - response: string; - error: string | null; - targetSectionId: string; -} +type ChatResult = + | { + error: string; + } + | { + error: null; + // サーバー側でデータベースに新しく追加されたチャットデータ + chat: ChatWithMessages; + }; type ChatParams = { userQuestion: string; + docsId: string; documentContent: string; sectionContent: DynamicMarkdownSection[]; replOutputs: Record; @@ -20,7 +26,7 @@ type ChatParams = { execResults: Record; }; -export async function askAI(params: ChatParams): Promise { +export async function askAI(params: ChatParams): Promise { // const parseResult = ChatSchema.safeParse(params); // if (!parseResult.success) { @@ -141,25 +147,21 @@ export async function askAI(params: ChatParams): Promise { if (!text) { throw new Error("AIからの応答が空でした"); } + // TODO: どのセクションへの回答にするかをAIに決めさせる + const targetSectionId = + sectionContent.find((s) => s.inView)?.sectionId || ""; + const newChat = await addChat(params.docsId, targetSectionId, [ + { role: "user", content: userQuestion }, + { role: "ai", content: text }, + ]); return { - response: text, error: null, - // TODO: どのセクションへの回答にするかをAIに決めさせる - targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "", + chat: newChat, }; } catch (error: unknown) { console.error("Error calling Generative AI:", error); - if (error instanceof Error) { - return { - response: "", - error: `AIへのリクエスト中にエラーが発生しました: ${error.message}`, - targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "", - }; - } return { - response: "", - error: "予期せぬエラーが発生しました。", - targetSectionId: sectionContent.find((s) => s.inView)?.sectionId || "", + error: String(error), }; } } diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..fcb1ef0 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; // path to your auth file +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); diff --git a/app/layout.tsx b/app/layout.tsx index 365472f..2589c54 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import { ReactNode } from "react"; import { PyodideProvider } from "./terminal/python/pyodide"; import { WandboxProvider } from "./terminal/wandbox/wandbox"; import { EmbedContextProvider } from "./terminal/embedContext"; +import { AutoAnonymousLogin } from "./accountMenu"; export const metadata: Metadata = { title: "Create Next App", @@ -20,6 +21,7 @@ export default function RootLayout({ return ( +
diff --git a/app/lib/auth-client.ts b/app/lib/auth-client.ts new file mode 100644 index 0000000..811590c --- /dev/null +++ b/app/lib/auth-client.ts @@ -0,0 +1,7 @@ +import { anonymousClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; +export const authClient = createAuthClient({ + /** The base URL of the server (optional if you're using the same domain) */ + // baseURL: "http://localhost:3000" + plugins: [anonymousClient()], +}); diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 0000000..1b91e16 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,38 @@ +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { anonymous } from "better-auth/plugins"; +import prisma from "./prisma"; +import { migrateChatUser } from "./chatHistory"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let cloudflareEnv: any; +try { + cloudflareEnv = getCloudflareContext().env; +} catch { + // @better-auth/cli generate を実行する際には initOpenNextCloudflareForDev がセットアップされていない環境になっている + cloudflareEnv = {}; +} +export const auth = betterAuth({ + database: prismaAdapter(prisma, { + provider: "postgresql", + }), + plugins: [ + anonymous({ + onLinkAccount: ({ anonymousUser, newUser }) => + migrateChatUser(anonymousUser.user.id, newUser.user.id), + }), + ], + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID ?? cloudflareEnv.GITHUB_CLIENT_ID, + clientSecret: + process.env.GITHUB_CLIENT_SECRET ?? cloudflareEnv.GITHUB_CLIENT_SECRET, + }, + google: { + clientId: process.env.GOOGLE_CLIENT_ID ?? cloudflareEnv.GOOGLE_CLIENT_ID, + clientSecret: + process.env.GOOGLE_CLIENT_SECRET ?? cloudflareEnv.GOOGLE_CLIENT_SECRET, + }, + }, +}); diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts new file mode 100644 index 0000000..a8faf0b --- /dev/null +++ b/app/lib/chatHistory.ts @@ -0,0 +1,72 @@ +import { headers } from "next/headers"; +import { auth } from "./auth"; +import prisma from "./prisma"; + +export interface CreateChatMessage { + role: "user" | "ai" | "error"; + content: string; +} + +export async function addChat( + docsId: string, + sectionId: string, + messages: CreateChatMessage[] +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + throw new Error("Not authenticated"); + } + + return await prisma.chat.create({ + data: { + userId: session.user.id, + docsId, + sectionId, + messages: { + createMany: { + data: messages, + }, + }, + }, + include: { + messages: true, + }, + }); +} + +export type ChatWithMessages = Awaited>; + +export async function getChat(docsId: string) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return []; + } + + return await prisma.chat.findMany({ + where: { + userId: session.user.id, + docsId, + }, + include: { + messages: { + orderBy: { + createdAt: "asc", + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); +} + +export async function migrateChatUser(oldUserId: string, newUserId: string) { + await prisma.chat.updateMany({ + where: { + userId: oldUserId, + }, + data: { + userId: newUserId, + }, + }); +} diff --git a/app/lib/prisma.ts b/app/lib/prisma.ts new file mode 100644 index 0000000..72dd8ef --- /dev/null +++ b/app/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "../generated/prisma/client"; + +const prisma = new PrismaClient(); +export default prisma; + diff --git a/app/navbar.tsx b/app/navbar.tsx index b599410..2955d59 100644 --- a/app/navbar.tsx +++ b/app/navbar.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./[docs_id]/themeToggle"; export function Navbar() { return ( @@ -32,6 +33,7 @@ export function Navbar() { {/* サイドバーが常時表示されている場合のみ */} my.code(); +
); diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 8e263ab..1afa75b 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -4,6 +4,7 @@ import { usePathname } from "next/navigation"; import useSWR, { Fetcher } from "swr"; import { splitMarkdown } from "./[docs_id]/splitMarkdown"; import { pagesList } from "./pagesList"; +import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./[docs_id]/themeToggle"; const fetcher: Fetcher = (url) => @@ -20,12 +21,13 @@ export function Sidebar() { return (
{/* todo: 背景色ほんとにこれでいい? */} -

+

{/* サイドバーが常時表示されている場合のみ */} my.code(); +