diff --git a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx index f38ce7ee..1a1abb6e 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, FormEvent, useEffect } from "react"; +import { useState, FormEvent, useEffect, useRef, useCallback } from "react"; // import useSWR from "swr"; // import { // getQuestionExample, @@ -9,9 +9,10 @@ import { useState, FormEvent, useEffect } from "react"; // import { getLanguageName } from "../pagesList"; import { useEmbedContext } from "@/terminal/embedContext"; import { DynamicMarkdownSection, PagePath } from "@/lib/docs"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { ChatStreamEvent } from "@/api/chat/route"; import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; +import { revalidateChatAction } from "@/actions/revalidateChat"; interface ChatFormProps { path: PagePath; @@ -32,6 +33,37 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const router = useRouter(); const streamingChatContext = useStreamingChatContext(); + const pathname = usePathname(); + const pendingRouterPushTarget = useRef(null); + const pendingRouterPushResolver = useRef void)>(null); + // router.pushの完了を待つ関数。pathnameの変化でページ遷移の完了を検知し、解決する。 + const asyncRouterPush = useCallback( + (url: string, options?: { scroll?: boolean }) => { + if (pendingRouterPushTarget.current) { + console.error( + "Already navigating to", + pendingRouterPushTarget.current, + "can't navigate to", + url + ); + return; + } + pendingRouterPushTarget.current = url; + return new Promise((resolve) => { + pendingRouterPushResolver.current = resolve; + router.push(url, options); + }); + }, + [router] + ); + useEffect(() => { + if (pendingRouterPushTarget.current === pathname) { + pendingRouterPushResolver.current?.(); + pendingRouterPushTarget.current = null; + pendingRouterPushResolver.current = null; + } + }, [pathname]); + // const documentContentInView = sectionContent // .filter((s) => s.inView) // .map((s) => s.rawContent) @@ -98,6 +130,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let chatId: string | null = null; let navigated = false; // ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続) @@ -118,11 +151,16 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const event = JSON.parse(line) as ChatStreamEvent; if (event.type === "chat") { + // revalidateChatは/api/chatの中では呼ばず、別のServerActionとして呼び出す + await revalidateChatAction(event.chatId, path); + chatId = event.chatId; streamingChatContext.startStreaming(event.chatId); document.getElementById(event.sectionId)?.scrollIntoView({ behavior: "smooth", }); - router.push(`/chat/${event.chatId}`, { scroll: false }); + await asyncRouterPush(`/chat/${event.chatId}`, { + scroll: false, + }); router.refresh(); navigated = true; setIsLoading(false); @@ -131,6 +169,9 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { } else if (event.type === "chunk") { streamingChatContext.appendChunk(event.text); } else if (event.type === "done") { + if (chatId) { + await revalidateChatAction(chatId, path); + } streamingChatContext.finishStreaming(); router.refresh(); } else if (event.type === "error") { @@ -138,7 +179,11 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { setErrorMessage(event.message); setIsLoading(false); } + if (chatId) { + await revalidateChatAction(chatId, path); + } streamingChatContext.finishStreaming(); + router.refresh(); } } catch { // ignore JSON parse errors diff --git a/app/actions/deleteChat.ts b/app/actions/deleteChat.ts index 65ec9507..9edcac82 100644 --- a/app/actions/deleteChat.ts +++ b/app/actions/deleteChat.ts @@ -1,10 +1,22 @@ "use server"; import { z } from "zod"; -import { deleteChat, initContext } from "@/lib/chatHistory"; +import { deleteChat, initContext, revalidateChat } from "@/lib/chatHistory"; +import { section } from "@/schema/chat"; +import { eq } from "drizzle-orm"; export async function deleteChatAction(chatId: string) { chatId = z.uuid().parse(chatId); const ctx = await initContext(); - await deleteChat(chatId, ctx); + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + const deletedChat = await deleteChat(chatId, ctx); + + const targetSection = await ctx.drizzle.query.section.findFirst({ + where: eq(section.sectionId, deletedChat[0].sectionId), + }); + if (targetSection) { + await revalidateChat(chatId, ctx.userId, targetSection.pagePath); + } } diff --git a/app/actions/revalidateChat.ts b/app/actions/revalidateChat.ts new file mode 100644 index 00000000..1f502ece --- /dev/null +++ b/app/actions/revalidateChat.ts @@ -0,0 +1,26 @@ +"use server"; + +import { initContext, revalidateChat } from "@/lib/chatHistory"; +import { PagePath, PagePathSchema } from "@/lib/docs"; +import { z } from "zod"; + +export async function revalidateChatAction( + chatId: string, + pagePath: string | PagePath +) { + chatId = z.uuid().parse(chatId); + if (typeof pagePath === "string") { + if (!/^[a-z0-9_-]+\/[a-z0-9_-]+$/.test(pagePath)) { + throw new Error("Invalid pagePath format"); + } + const [lang, page] = pagePath.split("/"); + pagePath = PagePathSchema.parse({ lang, page }); + } else { + pagePath = PagePathSchema.parse(pagePath); + } + const ctx = await initContext(); + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + await revalidateChat(chatId, ctx.userId, pagePath); +} diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index c2182006..e1392102 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -28,7 +28,11 @@ export function cacheKeyForChat(chatId: string) { return `${CACHE_KEY_BASE}/getChatOne?chatId=${chatId}`; } -async function revalidateChat( +// nextjsのキャッシュのrevalidateはRouteHandlerではなくServerActionから呼ばないと正しく動作しないらしい。 +// https://github.com/vercel/next.js/issues/69064 +// そのためlib/以下の関数では直接revalidateChatを呼ばず、ServerActionの関数から呼ぶようにする。 +// Nextjs 16 に更新したらこれをupdateTag()で置き換える。 +export async function revalidateChat( chatId: string, userId: string, pagePath: string | PagePath @@ -126,8 +130,6 @@ export async function addChat( chatDiffs = [] as never[]; } - await revalidateChat(newChat.chatId, userId, path); - return { ...newChat, section: { @@ -173,8 +175,6 @@ export async function addMessagesAndDiffs( })) ); } - - await revalidateChat(chatId, userId, path); } export async function deleteChat(chatId: string, context: Context) { @@ -192,10 +192,7 @@ export async function deleteChat(chatId: string, context: Context) { await drizzle.delete(message).where(eq(message.chatId, chatId)); await drizzle.delete(diff).where(eq(diff.chatId, chatId)); - const targetSection = await drizzle.query.section.findFirst({ - where: eq(section.sectionId, deletedChat[0].sectionId), - }); - await revalidateChat(chatId, userId, targetSection?.pagePath ?? ""); + return deletedChat; } export async function getAllChat(