diff --git a/README.md b/README.md index b4246e8..9b77a38 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,30 @@ https://my-code.utcode.net ## インストール + ```bash npm ci ``` -ルートディレクトリに .env.local という名前のファイルを作成し、Gemini APIキーを設定してください +## 開発環境 + +```bash +npx prisma dev +``` +を実行し、`t` キーを押して表示される DATABASE_URL をコピー + +ルートディレクトリに .env.local という名前のファイルを作成し、以下の内容を記述 ```dotenv -API_KEY="XXXXXXXX" +API_KEY=GeminiAPIキー +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL="postgres://... (prisma devの出力)" ``` -## 開発環境 +別のターミナルで、 +```bash +npx drizzle-kit migrate +``` +でデータベースを初期化 ```bash npm run dev @@ -29,6 +43,18 @@ npm run lint ``` でコードをチェックします。出てくるwarningやerrorはできるだけ直しましょう。 +* データベースのスキーマ(./app/schema/hoge.ts)を編集した場合、 `npx drizzle-kit generate` でmigrationファイルを作成し、 `npx drizzle-kit migrate` でデータベースに反映します。 + * また、mainにマージする際に本番環境のデータベースにもmigrateをする必要があります +* スキーマのファイルを追加した場合は app/lib/drizzle.ts でimportを追加する必要があります(たぶん) +* `npx prisma dev` で立ち上げたデータベースは `npx prisma dev ls` でデータベース名の確認・ `npx prisma dev rm default` で削除ができるらしい + +### 本番環境の場合 + +上記の環境変数以外に、 +* BETTER_AUTH_SECRET に任意の文字列 +* 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 + ## markdown仕様 ```` 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..7d35e55 --- /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..36bb229 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,8 @@ +import { getAuthServer } from "@/lib/auth"; +import { getDrizzle } from "@/lib/drizzle"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler({ + handler: async (req) => + (await getAuthServer(await getDrizzle())).handler(req), +}); 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..2bee1c7 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,49 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { anonymous } from "better-auth/plugins"; +import { migrateChatUser } from "./chatHistory"; +import { getDrizzle } from "./drizzle"; + +export async function getAuthServer( + drizzle: Awaited> +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let cloudflareEnv: any; + try { + cloudflareEnv = getCloudflareContext().env; + } catch { + // @better-auth/cli generate を実行する際には initOpenNextCloudflareForDev がセットアップされていない環境になっている + cloudflareEnv = {}; + } + return betterAuth({ + database: drizzleAdapter(drizzle, { + provider: "pg", + }), + 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, + }, + }, + }); +} + +// @better-auth/cli を実行するときだけ以下のコメントアウトを解除 +// export const auth = await getAuthServer(await getDrizzle()); diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts new file mode 100644 index 0000000..5e37c07 --- /dev/null +++ b/app/lib/chatHistory.ts @@ -0,0 +1,79 @@ +import { headers } from "next/headers"; +import { getAuthServer } from "./auth"; +import { getDrizzle } from "./drizzle"; +import { chat, message } from "@/schema/chat"; +import { and, asc, eq } from "drizzle-orm"; + +export interface CreateChatMessage { + role: "user" | "ai" | "error"; + content: string; +} + +export async function addChat( + docsId: string, + sectionId: string, + messages: CreateChatMessage[] +) { + const drizzle = await getDrizzle(); + const auth = await getAuthServer(drizzle); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + throw new Error("Not authenticated"); + } + + const [newChat] = await drizzle + .insert(chat) + .values({ + userId: session.user.id, + docsId, + sectionId, + }) + .returning(); + + const chatMessages = await drizzle + .insert(message) + .values( + messages.map((msg) => ({ + chatId: newChat.chatId, + role: msg.role, + content: msg.content, + })) + ) + .returning(); + + return { + ...newChat, + messages: chatMessages, + }; +} + +export type ChatWithMessages = Awaited>; + +export async function getChat(docsId: string) { + const drizzle = await getDrizzle(); + const auth = await getAuthServer(drizzle); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return []; + } + + const chats = await drizzle.query.chat.findMany({ + where: and(eq(chat.userId, session.user.id), eq(chat.docsId, docsId)), + with: { + messages: { + orderBy: [asc(message.createdAt)], + }, + }, + orderBy: [asc(chat.createdAt)], + }); + + return chats; +} + +export async function migrateChatUser(oldUserId: string, newUserId: string) { + const drizzle = await getDrizzle(); + await drizzle + .update(chat) + .set({ userId: newUserId }) + .where(eq(chat.userId, oldUserId)); +} diff --git a/app/lib/drizzle.ts b/app/lib/drizzle.ts new file mode 100644 index 0000000..8718088 --- /dev/null +++ b/app/lib/drizzle.ts @@ -0,0 +1,31 @@ +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as authSchema from "../schema/auth"; +import * as chatSchema from "../schema/chat"; + +export async function getDrizzle() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let cloudflareEnv: any; + try { + cloudflareEnv = (await getCloudflareContext({ async: true })).env; + } catch { + // @better-auth/cli generate を実行する際には initOpenNextCloudflareForDev がセットアップされていない環境になっている + cloudflareEnv = {}; + } + const DATABASE_URL = + process.env.DATABASE_URL ?? cloudflareEnv.DATABASE_URL ?? ""; + + const pool = new Pool({ + connectionString: DATABASE_URL, + // You don't want to reuse the same connection for multiple requests + maxUses: 1, + }); + return drizzle({ + client: pool, + schema: { + ...authSchema, + ...chatSchema, + }, + }); +} 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/schema/auth.ts b/app/schema/auth.ts new file mode 100644 index 0000000..e0e1f52 --- /dev/null +++ b/app/schema/auth.ts @@ -0,0 +1,62 @@ +import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + isAnonymous: boolean("is_anonymous"), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); diff --git a/app/schema/chat.ts b/app/schema/chat.ts new file mode 100644 index 0000000..722071d --- /dev/null +++ b/app/schema/chat.ts @@ -0,0 +1,29 @@ +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const chat = pgTable("chat", { + chatId: uuid("chatId").primaryKey().defaultRandom(), + userId: text("userId").notNull(), + docsId: text("docsId").notNull(), + sectionId: text("sectionId").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export const message = pgTable("message", { + id: uuid("id").primaryKey().defaultRandom(), + chatId: uuid("chatId").notNull(), + role: text("role").notNull(), + content: text("content").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export const chatRelations = relations(chat, ({ many }) => ({ + messages: many(message), +})); + +export const messageRelations = relations(message, ({ one }) => ({ + chat: one(chat, { + fields: [message.chatId], + references: [chat.chatId], + }), +})); 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(); +