diff --git a/app/[docs_id]/chatHistory.tsx b/app/[docs_id]/chatHistory.tsx index fc92e5d..184c2eb 100644 --- a/app/[docs_id]/chatHistory.tsx +++ b/app/[docs_id]/chatHistory.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChatWithMessages } from "@/lib/chatHistory"; +import { ChatWithMessages, getChat } from "@/lib/chatHistory"; import { createContext, ReactNode, @@ -8,6 +8,7 @@ import { useEffect, useState, } from "react"; +import useSWR from "swr"; export interface IChatHistoryContext { chatHistories: ChatWithMessages[]; @@ -27,19 +28,38 @@ export function useChatHistoryContext() { export function ChatHistoryProvider({ children, + docs_id, initialChatHistories, }: { children: ReactNode; + docs_id: string; initialChatHistories: ChatWithMessages[]; }) { const [chatHistories, setChatHistories] = useState(initialChatHistories); + // 最初はSSRで取得したinitialChatHistoriesを使用(キャッシュされているので古い可能性がある) useEffect(() => { setChatHistories(initialChatHistories); }, [initialChatHistories]); + // その後、クライアント側で最新のchatHistoriesを改めて取得して更新する + const { data: fetchedChatHistories } = useSWR( + docs_id, + getChat, + { + // リクエストは古くても構わないので1回でいい + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + useEffect(() => { + if (fetchedChatHistories) { + setChatHistories(fetchedChatHistories); + } + }, [fetchedChatHistories]); + // チャットを更新した際にはクライアントサイドでchatHistoryに反映する const addChat = (chat: ChatWithMessages) => { - // サーバー側で追加された新しいchatをクライアント側にも反映する setChatHistories([...chatHistories, chat]); }; diff --git a/app/[docs_id]/dynamicMdContext.tsx b/app/[docs_id]/dynamicMdContext.tsx deleted file mode 100644 index 779ff4e..0000000 --- a/app/[docs_id]/dynamicMdContext.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { createContext, ReactNode, useContext, useState } from "react"; -import { DynamicMarkdownSection } from "./pageContent"; - -export interface ISidebarMdContext { - sidebarMdContent: DynamicMarkdownSection[]; - setSidebarMdContent: React.Dispatch>; -} - -const SidebarMdContext = createContext(null); - -export function useSidebarMdContext() { - const context = useContext(SidebarMdContext); - if (!context) { - throw new Error( - "useSidebarMdContext must be used within a SidebarMdProvider" - ); - } - return context; -} - -export function useSidebarMdContextOptional() { - return useContext(SidebarMdContext); -} - -export function SidebarMdProvider({ - children, -}: { - children: ReactNode; -}) { - const [sidebarMdContent, setSidebarMdContent] = - useState([]); - - return ( - - {children} - - ); -} diff --git a/app/[docs_id]/loading.tsx b/app/[docs_id]/loading.tsx new file mode 100644 index 0000000..272e542 --- /dev/null +++ b/app/[docs_id]/loading.tsx @@ -0,0 +1,12 @@ +export default function Loading() { + return ( +
+
{/* heading1 */}
+
{/*

*/}

+
{/* heading2 */}
+
{/*

*/}

+
{/* heading2 */}
+
{/*

*/}

+
+ ); +} diff --git a/app/[docs_id]/markdown.tsx b/app/[docs_id]/markdown.tsx index b985c60..b3a8c03 100644 --- a/app/[docs_id]/markdown.tsx +++ b/app/[docs_id]/markdown.tsx @@ -1,7 +1,7 @@ import Markdown, { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import SyntaxHighlighter from "react-syntax-highlighter"; -import { type AceLang, EditorComponent, getAceLang } from "../terminal/editor"; +import { EditorComponent, getAceLang } from "../terminal/editor"; import { ExecFile } from "../terminal/exec"; import { useChangeTheme } from "./themeToggle"; import { @@ -9,7 +9,7 @@ import { atomOneDark, } from "react-syntax-highlighter/dist/esm/styles/hljs"; import { ReactNode } from "react"; -import { getRuntimeLang, RuntimeLang } from "@/terminal/runtime"; +import { getRuntimeLang } from "@/terminal/runtime"; import { ReplTerminal } from "@/terminal/repl"; export function StyledMarkdown({ content }: { content: string }) { diff --git a/app/[docs_id]/page.tsx b/app/[docs_id]/page.tsx index cf41646..2841b68 100644 --- a/app/[docs_id]/page.tsx +++ b/app/[docs_id]/page.tsx @@ -6,7 +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"; +import { getChatFromCache } from "@/lib/chatHistory"; export default async function Page({ params, @@ -50,10 +50,10 @@ export default async function Page({ splitMarkdown(text) ); - const initialChatHistories = getChat(docs_id); + const initialChatHistories = getChatFromCache(docs_id); return ( - + { - setSidebarMdContent([]); - }; + setSidebarMdContent(props.docs_id, newContent); }, [props.splitMdContent, props.docs_id, setSidebarMdContent]); const sectionRefs = useRef>([]); @@ -57,28 +52,31 @@ export function PageContent(props: PageContentProps) { useEffect(() => { const handleScroll = () => { - const updateContent = (prevDynamicMdContent: DynamicMarkdownSection[]) => { + const updateContent = ( + prevDynamicMdContent: DynamicMarkdownSection[] + ) => { const dynMdContent = prevDynamicMdContent.slice(); // Reactの変更検知のために新しい配列を作成 for (let i = 0; i < sectionRefs.current.length; i++) { if (sectionRefs.current.at(i) && dynMdContent.at(i)) { const rect = sectionRefs.current.at(i)!.getBoundingClientRect(); dynMdContent.at(i)!.inView = - rect.top < window.innerHeight && rect.bottom >= 0; + rect.top < window.innerHeight * 0.9 && + rect.bottom >= window.innerHeight * 0.1; } } return dynMdContent; }; - + // ローカルstateとcontextの両方を更新 setDynamicMdContent(updateContent); - setSidebarMdContent(updateContent); + setSidebarMdContent(props.docs_id, updateContent); }; window.addEventListener("scroll", handleScroll); handleScroll(); return () => { window.removeEventListener("scroll", handleScroll); }; - }, [setSidebarMdContent]); + }, [setSidebarMdContent, props.docs_id]); const [isFormVisible, setIsFormVisible] = useState(false); @@ -107,8 +105,9 @@ export function PageContent(props: PageContentProps) {
{/* 右側に表示するチャット履歴欄 */} - {chatHistories.filter((c) => c.sectionId === section.sectionId).map( - ({chatId, messages}) => ( + {chatHistories + .filter((c) => c.sectionId === section.sectionId) + .map(({ chatId, messages }) => (
- ) - )} + ))}
))} diff --git a/app/layout.tsx b/app/layout.tsx index dd56653..fcf18de 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,7 @@ import { Sidebar } from "./sidebar"; import { ReactNode } from "react"; import { EmbedContextProvider } from "./terminal/embedContext"; import { AutoAnonymousLogin } from "./accountMenu"; -import { SidebarMdProvider } from "./[docs_id]/dynamicMdContext"; +import { SidebarMdProvider } from "./sidebar"; import { RuntimeProvider } from "./terminal/runtime"; export const metadata: Metadata = { diff --git a/app/lib/cache.ts b/app/lib/cache.ts new file mode 100644 index 0000000..344f4f4 --- /dev/null +++ b/app/lib/cache.ts @@ -0,0 +1,20 @@ +const cacheData: Map = new Map(); +/** + * nodejsにcache apiがないので、web標準のcache APIに相当するものの自前実装 + */ +export const inMemoryCache = { + async put(key: string, response: Response): Promise { + const arrayBuffer = await response.arrayBuffer(); + cacheData.set(key, arrayBuffer); + }, + async match(key: string): Promise { + const arrayBuffer = cacheData.get(key); + if (arrayBuffer) { + return new Response(arrayBuffer); + } + return undefined; + }, + async delete(key: string): Promise { + return cacheData.delete(key); + }, +} as const; diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 5e37c07..915f435 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -1,30 +1,74 @@ +"use server"; + 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"; +import { Auth } from "better-auth"; +import { inMemoryCache } from "./cache"; export interface CreateChatMessage { role: "user" | "ai" | "error"; content: string; } +// cacheに使うキーで、実際のURLではない +const CACHE_KEY_BASE = "https://my-code.utcode.net/chatHistory"; + +interface Context { + drizzle: Awaited>; + auth: Auth; + userId: string; +} +/** + * drizzleが初期化されてなければ初期化し、 + * authが初期化されてなければ初期化し、 + * userIdがなければセッションから取得してセットする。 + */ +async function initAll(ctx?: Partial): Promise { + if (!ctx) { + ctx = {}; + } + if (!ctx.drizzle) { + ctx.drizzle = await getDrizzle(); + } + if (!ctx.auth) { + ctx.auth = await getAuthServer(ctx.drizzle); + } + if (!ctx.userId) { + const session = await ctx.auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + throw new Error("Not authenticated"); + } + ctx.userId = session.user.id; + } + return ctx as Context; +} +async function getCache() { + if ("caches" in globalThis) { + // worker + return await caches.open("chatHistory"); + } else { + // nodejs + return inMemoryCache; + } +} + export async function addChat( docsId: string, sectionId: string, - messages: CreateChatMessage[] + messages: CreateChatMessage[], + context?: Partial ) { - 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 { drizzle, userId } = await initAll(context); const [newChat] = await drizzle .insert(chat) .values({ - userId: session.user.id, + userId, docsId, sectionId, }) @@ -41,6 +85,14 @@ export async function addChat( ) .returning(); + console.log( + `deleting cache for chatHistory/getChat for user ${userId} and docs ${docsId}` + ); + const cache = await getCache(); + await cache.delete( + `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}` + ); + return { ...newChat, messages: chatMessages, @@ -49,16 +101,14 @@ export async function addChat( 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 []; - } +export async function getChat( + docsId: string, + context?: Partial +): Promise { + const { drizzle, userId } = await initAll(context); const chats = await drizzle.query.chat.findMany({ - where: and(eq(chat.userId, session.user.id), eq(chat.docsId, docsId)), + where: and(eq(chat.userId, userId), eq(chat.docsId, docsId)), with: { messages: { orderBy: [asc(message.createdAt)], @@ -67,8 +117,33 @@ export async function getChat(docsId: string) { orderBy: [asc(chat.createdAt)], }); + const cache = await getCache(); + await cache.put( + `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}`, + new Response(JSON.stringify(chats), { + headers: { "Cache-Control": "max-age=86400, s-maxage=86400" }, + }) + ); return chats; } +export async function getChatFromCache( + docsId: string, + context?: Partial +) { + const { drizzle, auth, userId } = await initAll(context); + + const cache = await getCache(); + const cachedResponse = await cache.match( + `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}` + ); + if (cachedResponse) { + console.log("Cache hit for chatHistory/getChat"); + const data = (await cachedResponse.json()) as ChatWithMessages[]; + return data; + } + console.log("Cache miss for chatHistory/getChat"); + return await getChat(docsId, { drizzle, auth, userId }); +} export async function migrateChatUser(oldUserId: string, newUserId: string) { const drizzle = await getDrizzle(); diff --git a/app/sidebar.tsx b/app/sidebar.tsx index b95fda3..4ee2f06 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -4,17 +4,107 @@ import { usePathname } from "next/navigation"; import { pagesList } from "./pagesList"; import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./[docs_id]/themeToggle"; -import { useSidebarMdContext } from "./[docs_id]/dynamicMdContext"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { DynamicMarkdownSection } from "./[docs_id]/pageContent"; + +export interface ISidebarMdContext { + loadedDocsId: string; + sidebarMdContent: DynamicMarkdownSection[]; + setSidebarMdContent: ( + docsId: string, + content: + | DynamicMarkdownSection[] + | ((prev: DynamicMarkdownSection[]) => DynamicMarkdownSection[]) + ) => void; +} + +const SidebarMdContext = createContext(null); + +export function useSidebarMdContext() { + const context = useContext(SidebarMdContext); + if (!context) { + throw new Error( + "useSidebarMdContext must be used within a SidebarMdProvider" + ); + } + return context; +} + +export function useSidebarMdContextOptional() { + return useContext(SidebarMdContext); +} + +export function SidebarMdProvider({ children }: { children: ReactNode }) { + const [sidebarMdContent, setSidebarMdContent_] = useState< + DynamicMarkdownSection[] + >([]); + const [loadedDocsId, setLoadedDocsId] = useState(""); + const setSidebarMdContent = useCallback( + ( + docsId: string, + content: + | DynamicMarkdownSection[] + | ((prev: DynamicMarkdownSection[]) => DynamicMarkdownSection[]) + ) => { + setLoadedDocsId(docsId); + setSidebarMdContent_(content); + }, + [] + ); + return ( + + {children} + + ); +} export function Sidebar() { const pathname = usePathname(); - const docs_id = pathname.replace(/^\//, ""); - const { sidebarMdContent } = useSidebarMdContext(); - + const currentDocsId = pathname.replace(/^\//, ""); // ちょっと遅延がある + const sidebarContext = useSidebarMdContext(); + // sidebarMdContextの情報が古かったら使わない + const sidebarMdContent = + sidebarContext.loadedDocsId === currentDocsId + ? sidebarContext.sidebarMdContent + : []; + // 現在表示中のセクション(最初にinViewがtrueのもの)を見つける const currentSectionIndex = sidebarMdContent.findIndex( - (section) => section.inView + (section, i) => i >= 1 && section.inView + ); + + // 目次の開閉状態 + const [detailsOpen, setDetailsOpen] = useState([]); + const currentGroupIndex = pagesList.findIndex((group) => + currentDocsId.startsWith(`${group.id}-`) ); + useEffect(() => { + // 表示しているグループが変わったときに現在のグループのdetailsを開く + if (currentGroupIndex !== -1) { + setDetailsOpen((detailsOpen) => { + const newDetailsOpen = [...detailsOpen]; + while (newDetailsOpen.length <= currentGroupIndex) { + newDetailsOpen.push(false); + } + newDetailsOpen[currentGroupIndex] = true; + return newDetailsOpen; + }); + } + }, [currentGroupIndex]); + return (
{/* todo: 背景色ほんとにこれでいい? */} @@ -51,9 +141,19 @@ export function Sidebar() {
    - {pagesList.map((group) => ( + {pagesList.map((group, i) => (
  • -
    +
    { + const newDetailsOpen = [...detailsOpen]; + while (newDetailsOpen.length <= i) { + newDetailsOpen.push(false); + } + newDetailsOpen[i] = e.currentTarget.open; + setDetailsOpen(newDetailsOpen); + }} + > {group.lang}
      {group.pages.map((page) => ( @@ -62,27 +162,31 @@ export function Sidebar() { {page.id}. {page.title} - {`${group.id}-${page.id}` === docs_id && sidebarMdContent.length > 0 && ( -
        - {sidebarMdContent.slice(1).map((section, idx) => { - // idx + 1 は実際のsectionIndexに対応(slice(1)で最初を除外しているため) - const isCurrentSection = idx + 1 === currentSectionIndex; - return ( -
      • - 0 && ( +
          + {sidebarMdContent.slice(1).map((section, idx) => { + // idx + 1 は実際のsectionIndexに対応(slice(1)で最初を除外しているため) + const isCurrentSection = + idx + 1 === currentSectionIndex; + return ( +
        • - {section.title} - -
        • - ); - })} -
        - )} + + {section.title} + +
      • + ); + })} +
      + )} ))}