diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5331b01b..0b27ffec 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -35,6 +35,21 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run tsc + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22.x] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm run test + check-docs: runs-on: ubuntu-latest strategy: diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index 0f5413b0..63bef250 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -2,9 +2,10 @@ import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState"; import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; +import { useSendChat } from "@/(docs)/useSendChat"; import { deleteChatAction } from "@/actions/deleteChat"; import { ChatWithMessages } from "@/lib/chatHistory"; -import { LangId, MarkdownSection, PageSlug } from "@/lib/docs"; +import { DynamicMarkdownSection, LangId, MarkdownSection, PageSlug } from "@/lib/docs"; import { Heading } from "@/markdown/heading"; import { StyledMarkdown } from "@/markdown/markdown"; import { usePagesListForLang } from "@/pagesListContext"; @@ -74,9 +75,17 @@ interface Props { langId: LangId; pageSlug: PageSlug; targetSection: MarkdownSection | undefined; + priorSectionContent: DynamicMarkdownSection[]; } export function ChatAreaContent(props: Props) { - const { chatId, chatData, langId, pageSlug, targetSection } = props; + const { + chatId, + chatData, + langId, + pageSlug, + targetSection, + priorSectionContent, + } = props; const langEntry = usePagesListForLang(langId); const pageEntry = langEntry?.pages.find((p) => p.slug === pageSlug); @@ -92,6 +101,23 @@ export function ChatAreaContent(props: Props) { const router = useRouter(); const streamingChatContext = useStreamingChatContext(); const isStreamingThis = streamingChatContext.chatId === chatId; + const { sendChat, isLoading: isRegenerating } = useSendChat(); + + const handleRegenerate = async () => { + if (!confirm("このチャットを削除して再生成してもよろしいですか?")) { + return; + } + const firstUserMsg = chatData.messages.find((m) => m.role === "user"); + const userQuestion = firstUserMsg ? firstUserMsg.content : chatData.title; + + await sendChat({ + path: { lang: langId, page: pageSlug }, + userQuestion, + questionScope: "page", + sectionContent: priorSectionContent, + deleteChatOnCreated: chatId, + }); + }; return ( <> @@ -117,12 +143,55 @@ export function ChatAreaContent(props: Props) { -
+
{chatData.createdAt.toLocaleString()}
+ +
+ {isRegenerating && ( + + )} +
+ ); +} diff --git a/app/(docs)/useSendChat.ts b/app/(docs)/useSendChat.ts new file mode 100644 index 00000000..cc6719b3 --- /dev/null +++ b/app/(docs)/useSendChat.ts @@ -0,0 +1,180 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { captureException } from "@sentry/nextjs"; +import { useEmbedContext } from "@/terminal/embedContext"; +import { DynamicMarkdownSection, PagePath } from "@/lib/docs"; +import { ChatStreamEvent } from "@/api/chat/route"; +import { revalidateChatAction } from "@/actions/revalidateChat"; +import { useStreamingChatContext } from "./streamingChatContext"; + +export interface SendChatParams { + path: PagePath; + userQuestion: string; + questionScope?: "page" | "language"; + sectionContent: DynamicMarkdownSection[]; + deleteChatOnCreated?: string; + onSuccess?: () => void; +} + +/** + * チャットの作成・既存チャットの再生成で使う、クライアント側のチャットストリーミング描画・revalidate・ルーティングの関数 + */ +export function useSendChat() { + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const { files, replOutputs, execResults } = useEmbedContext(); + const router = useRouter(); + const streamingChatContext = useStreamingChatContext(); + + const sendChat = useCallback( + async ({ + path, + userQuestion, + questionScope = "page", + sectionContent, + deleteChatOnCreated, + onSuccess, + }: SendChatParams) => { + if (!userQuestion) return; + + setIsLoading(true); + setErrorMessage(null); + + let response: Response; + try { + response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + userQuestion, + questionScope, + sectionContent, + replOutputs, + files, + execResults, + deleteChatOnCreated, + }), + }); + } catch (e) { + captureException(e); + setErrorMessage("AIへの接続に失敗しました"); + setIsLoading(false); + return; + } + + if (!response.ok) { + setErrorMessage(`エラーが発生しました (${response.status})`); + setIsLoading(false); + return; + } + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let chatId: string | null = null; + let chatPagePath: string | PagePath = path; + let navigated = false; + + void (async () => { + try { + while (true) { + const result = await reader.read(); + const { done, value } = result; + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as ChatStreamEvent; + + if (event.type === "chat") { + chatPagePath = event.pagePath; + chatId = event.chatId; + + // 1. ストリーミング描画の開始 + streamingChatContext.startStreaming(event.chatId); + + // 2. 新チャットの再検証 + await revalidateChatAction(event.chatId, event.pagePath); + if (deleteChatOnCreated) { + await revalidateChatAction(deleteChatOnCreated, event.pagePath); + } + + // 3. セクションのスクロール + if (event.pagePath === `${path.lang}/${path.page}`) { + document.getElementById(event.sectionId)?.scrollIntoView({ + behavior: "smooth", + }); + } + + // 5. 新チャット画面へのルーティング & 更新 + router.push(`/chat/${event.chatId}`, { + scroll: false, + }); + router.refresh(); + + navigated = true; + setIsLoading(false); + onSuccess?.(); + } else if (event.type === "chunk") { + streamingChatContext.appendChunk(event.text); + } else if (event.type === "done") { + if (chatId) { + await revalidateChatAction(chatId, chatPagePath); + } + if (deleteChatOnCreated) { + await revalidateChatAction(deleteChatOnCreated, chatPagePath); + } + streamingChatContext.finishStreaming(); + router.refresh(); + } else if (event.type === "error") { + if (!navigated) { + setErrorMessage(event.message); + setIsLoading(false); + } + if (chatId) { + await revalidateChatAction(chatId, chatPagePath); + } + streamingChatContext.finishStreaming(); + router.refresh(); + } + } catch (e) { + captureException(e); + } + } + } + } catch (err) { + captureException(err); + console.error("Stream reading failed:", err); + if (!navigated) { + setErrorMessage(String(err)); + setIsLoading(false); + } + streamingChatContext.finishStreaming(); + } + })(); + }, + [ + execResults, + files, + replOutputs, + router, + streamingChatContext, + ] + ); + + return { + sendChat, + isLoading, + errorMessage, + setErrorMessage, + }; +} diff --git a/app/actions/getChat.ts b/app/actions/getChat.ts new file mode 100644 index 00000000..fab5896f --- /dev/null +++ b/app/actions/getChat.ts @@ -0,0 +1,25 @@ +"use server"; + +import { getChatOne, initContext } from "@/lib/chatHistory"; +import { setExtra, withServerActionInstrumentation } from "@sentry/nextjs"; +import { headers } from "next/headers"; +import { z } from "zod"; + +export async function getChatOneAction(chatId: string) { + return withServerActionInstrumentation( + "getChatOneAction", + { + headers: await headers(), + recordResponse: true, + }, + async () => { + setExtra("args", { chatId }); + chatId = z.uuid().parse(chatId); + const ctx = await initContext(); + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + return await getChatOne(chatId, ctx); + } + ); +} diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts new file mode 100644 index 00000000..7efcc12d --- /dev/null +++ b/app/api/chat/regenerate-section/route.ts @@ -0,0 +1,178 @@ +import { NextRequest } from "next/server"; +import { + applyChatDiff, + applySingleDiffToSection, + deleteChat, + getAllChat, + initContext, + revalidateChatOnDemand, +} from "@/lib/chatHistory"; +import { + DynamicMarkdownSection, + getMarkdownSections, + PagePathSchema, +} from "@/lib/docs"; +import { generateSingleChat } from "@/lib/chatGenerator"; +import { + ReplCommandSchema, + ReplOutputSchema, +} from "@my-code/runtime/interface"; +import { z } from "zod"; +import { captureException } from "@sentry/nextjs"; + +const RegenerateSectionSchema = z.object({ + path: PagePathSchema, + sectionId: z.string(), + replOutputs: z.record(z.string(), z.array(ReplCommandSchema)), + files: z.record(z.string(), z.string()), + execResults: z.record(z.string(), z.array(ReplOutputSchema)), +}); + +export type RegenerateStreamEvent = + | { type: "progress"; current: number; total: number } + | { type: "done"; deletedChatIds: string[]; createdChatIds: string[] } + | { type: "error"; message: string }; + +/** + * そのセクションの全chatを作成日順に再作成&削除します。 + * + * 既存のchatのdiffを無視して最新のcontentを渡して1つ目のchatを生成 + * →そのdiffを適用したcontentに対して2つ目のchatを作成 + * →その2つのdiffを適用したcontentに対して3つ目のchat... + * というように順番に作成する必要があります。 + */ +export async function POST(request: NextRequest) { + const context = await initContext(); + if (!context.userId) { + return new Response("Unauthorized", { status: 401 }); + } + + const parseResult = RegenerateSectionSchema.safeParse(await request.json()); + if (!parseResult.success) { + return new Response(JSON.stringify(parseResult.error), { status: 400 }); + } + const { path, sectionId, replOutputs, files, execResults } = parseResult.data; + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + function send(event: RegenerateStreamEvent) { + controller.enqueue(encoder.encode(JSON.stringify(event) + "\n")); + } + + try { + const rawSections = await getMarkdownSections(path.lang, path.page); + const chatHistories = await getAllChat(path, context); + + const targetChats = chatHistories.filter( + (c) => + c.sectionId === sectionId || + (rawSections[0]?.id === sectionId && + rawSections.every((sec) => c.sectionId !== sec.id)) + ); + + targetChats.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + if (targetChats.length === 0) { + send({ type: "done", deletedChatIds: [], createdChatIds: [] }); + controller.close(); + return; + } + + const targetChatIds = new Set(targetChats.map((c) => c.chatId)); + const nonTargetChats = chatHistories.filter( + (c) => !targetChatIds.has(c.chatId) + ); + + const baseSections = await applyChatDiff(rawSections, nonTargetChats, { + fallbackToPastVersion: false, + }); + + const currentSectionContent: DynamicMarkdownSection[] = baseSections.map( + (s) => ({ + ...s, + inView: false, + }) + ); + + const deletedChatIds: string[] = []; + const createdChatIds: string[] = []; + + for (let i = 0; i < targetChats.length; i++) { + const oldChat = targetChats[i]; + send({ type: "progress", current: i, total: targetChats.length }); + + try { + const firstUserMsg = oldChat.messages.find((m) => m.role === "user"); + const userQuestion = firstUserMsg ? firstUserMsg.content : oldChat.title; + + // 1. Generate new chat on server + const result = await generateSingleChat({ + path, + userQuestion, + sectionContent: currentSectionContent, + replOutputs, + files, + execResults, + context, + }); + createdChatIds.push(result.chatId); + + // 2. Delete old chat from DB + await deleteChat(oldChat.chatId, context); + deletedChatIds.push(oldChat.chatId); + + // 3. Apply newly generated diffs to currentSectionContent on server + for (const d of result.diffRaw) { + const targetSec = currentSectionContent.find( + (sec) => sec.id === d.sectionId + ); + if (targetSec) { + applySingleDiffToSection(targetSec, { + search: d.search, + replace: d.replace, + chatId: result.chatId, + }); + } + } + + // クライアントでもrevalidateChatActionを呼ぶが、一応こちらでもrevalidateしておく + await revalidateChatOnDemand(oldChat.chatId, context.userId!, path); + await revalidateChatOnDemand(result.chatId, context.userId!, path); + } catch (err) { + captureException(err); + console.error(`Failed to regenerate chat ${oldChat.chatId}:`, err); + } + } + + send({ type: "done", deletedChatIds, createdChatIds }); + controller.close(); + } catch (error: unknown) { + captureException(error); + console.error("Error in section regeneration:", error); + try { + controller.enqueue( + encoder.encode( + JSON.stringify({ type: "error", message: String(error) }) + "\n" + ) + ); + } catch { + // ignore + } + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-cache", + }, + }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index cca53f93..803b59cf 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,7 +4,9 @@ import { addChat, addMessagesAndDiffs, CreateChatDiff, + deleteChat, initContext, + revalidateChatOnDemand, } from "@/lib/chatHistory"; import { DynamicMarkdownSectionSchema, @@ -26,6 +28,7 @@ const ChatParamsSchema = z.object({ userQuestion: z.string().min(1), questionScope: z.enum(["page", "language"]).default("page"), sectionContent: z.array(DynamicMarkdownSectionSchema), + deleteChatOnCreated: z.string().optional(), replOutputs: z.record(z.string(), z.array(ReplCommandSchema)), files: z.record(z.string(), z.string()), execResults: z.record(z.string(), z.array(ReplOutputSchema)), @@ -52,6 +55,7 @@ export async function POST(request: NextRequest) { userQuestion, questionScope, sectionContent, + deleteChatOnCreated, replOutputs, files, execResults, @@ -404,6 +408,23 @@ export async function POST(request: NextRequest) { context ); + if (deleteChatOnCreated) { + try { + await deleteChat(deleteChatOnCreated, context); + } catch (e) { + console.error( + `Failed to delete old chat ${deleteChatOnCreated}:`, + e + ); + } + } + + // クライアントでもrevalidateChatActionを呼ぶが、一応こちらでもrevalidateしておく + if (deleteChatOnCreated) { + await revalidateChatOnDemand(deleteChatOnCreated, context.userId!, path); + } + await revalidateChatOnDemand(chatId, context.userId!, path); + send({ type: "done" }); controller.close(); } catch (error: unknown) { diff --git a/app/lib/chatGenerator.ts b/app/lib/chatGenerator.ts new file mode 100644 index 00000000..63ba0910 --- /dev/null +++ b/app/lib/chatGenerator.ts @@ -0,0 +1,298 @@ +import { generateContentStream } from "@/lib/ai"; +import { + addChat, + addMessagesAndDiffs, + Context, + CreateChatDiff, +} from "@/lib/chatHistory"; +import { + DynamicMarkdownSection, + getPagesListForLang, + introSectionId, + PagePath, + SectionId, +} from "@/lib/docs"; +import { + ReplCommand, + ReplOutput, +} from "@my-code/runtime/interface"; + +export interface GenerateSingleChatParams { + path: PagePath; + userQuestion: string; + sectionContent: DynamicMarkdownSection[]; + replOutputs: Record; + files: Record; + execResults: Record; + context: Context; + onChunk?: (text: string) => void; + onChatCreated?: (chatId: string, sectionId: SectionId) => void; +} + +export interface GenerateSingleChatResult { + chatId: string; + sectionId: SectionId; + title: string; + diffRaw: CreateChatDiff[]; + cleanMessage: string; +} + +export async function generateSingleChat( + params: GenerateSingleChatParams +): Promise { + const { + path, + userQuestion, + sectionContent, + replOutputs, + files, + execResults, + context, + onChunk, + onChatCreated, + } = params; + + const langEntry = await getPagesListForLang(path.lang); + const langName = langEntry?.name ?? path.lang; + const targetPath = path; + const targetSectionContent = sectionContent; + + const prompt: string[] = []; + prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); + prompt.push( + `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` + ); + prompt.push(``); + const sectionTitlesInView = targetSectionContent + .filter((s) => s.inView) + .map((s) => s.title); + if (sectionTitlesInView.length > 0) { + prompt.push( + `ユーザーはドキュメント内の ${sectionTitlesInView.join(", ")} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。` + ); + prompt.push( + `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` + ); + } + prompt.push(``); + prompt.push( + `質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。` + ); + prompt.push( + `質問内容とドキュメントの内容の関連性が深く、比較的長めの解説をしたい場合、またはドキュメントへの補足がしたい場合は、そちらの形式での回答を検討してください。` + ); + prompt.push(``); + prompt.push(`# ドキュメント`); + prompt.push(``); + for (const section of targetSectionContent) { + prompt.push(`[セクションid: ${section.id}]`); + prompt.push(section.replacedContent.trim()); + prompt.push(``); + } + prompt.push(``); + if (Object.keys(replOutputs).length > 0) { + prompt.push( + `# ターミナルのログ(ユーザーが入力したコマンドとその実行結果)` + ); + prompt.push(``); + prompt.push( + "以下はドキュメント内で実行例を示した各コードブロックの内容に加えてユーザーが追加で実行したコマンドです。" + ); + prompt.push( + "例えば ```python-repl:foo のコードブロックに対してユーザーが実行したログが ターミナル #foo です。" + ); + prompt.push(``); + for (const [replId, replCommands] of Object.entries(replOutputs)) { + prompt.push(`## ターミナル #${replId}`); + for (const replCmd of replCommands) { + prompt.push(`\n- コマンド: ${replCmd.command}`); + prompt.push("```"); + for (const output of replCmd.output) { + prompt.push(output.message); + } + prompt.push("```"); + } + prompt.push(``); + } + } + + if (Object.keys(files).length > 0) { + prompt.push("# ファイルエディターの内容"); + prompt.push(``); + prompt.push( + "以下はドキュメント内でファイルの内容を示した各コードブロックの内容に加えてユーザーが編集を加えたものです。" + ); + prompt.push( + "例えば ```python:foo.py のコードブロックに対してユーザーが編集した後の内容が ファイル: foo.py です。" + ); + prompt.push(``); + for (const [filename, content] of Object.entries(files)) { + prompt.push(`## ファイル: ${filename}`); + prompt.push("```"); + prompt.push(content); + prompt.push("```"); + prompt.push(``); + } + } + + if (Object.keys(execResults).length > 0) { + prompt.push("# ファイルの実行結果"); + prompt.push(``); + for (const [filename, outputs] of Object.entries(execResults)) { + prompt.push(`## ファイル: ${filename}`); + prompt.push("```"); + for (const output of outputs) { + prompt.push(output.message); + } + prompt.push("```"); + prompt.push(``); + } + } + + prompt.push("# 指示"); + prompt.push(""); + prompt.push( + `- 1行目に、ユーザーの質問ともっとも関連性の高いドキュメント内のセクションのidを回答してください。` + ); + prompt.push( + " - idのみを出力してください。 セクションid: や括弧や引用符などは不要です。" + ); + prompt.push( + " - ユーザーの質問がドキュメントのどのセクションとも直接的に関連しない場合は null と出力してください。" + ); + prompt.push( + "- 2行目に、この質問と回答を後から参照するためのわかりやすいタイトルをつけて記述してください。" + ); + prompt.push( + " - 太字やコードブロックなどのMarkdownの記法は使わずテキストのみで出力してください。" + ); + prompt.push( + "- 3行目以降に、ドキュメントの内容に基づいて、ユーザーに伝える回答をMarkdown形式で記述してください。" + ); + prompt.push( + " - ユーザーが入力したターミナルのコマンドやファイルの内容、実行結果を参考にして回答してください。" + ); + prompt.push(" - 必要であれば、具体的なコード例を提示してください。"); + prompt.push( + " - 回答内でコードブロックを使用する際は ```言語名 としてください。" + + "ドキュメント内では ```言語名-repl や ```言語名:ファイル名 、 ```言語名-exec:ファイル名 などの特殊なコードブロックが登場しますが、ユーザーへの回答ではこれらの記法は使用しないでください。" + ); + prompt.push("- ドキュメントの一部を改訂したい場合はその差分を"); + prompt.push("<<<<<<< SEARCH"); + prompt.push("修正したい元の文章の塊(一字一句違わずに)"); + prompt.push("======="); + prompt.push("修正後の新しい文章の塊"); + prompt.push(">>>>>>> REPLACE"); + prompt.push("の形式で出力してください。"); + prompt.push( + " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" + ); + prompt.push( + " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" + ); + prompt.push( + " - セクションid、セクション見出しを編集、追加、削除することはできません。" + ); + prompt.push( + " - ドキュメント内の特殊なコードブロック(```言語名-repl , ```言語名:ファイル名 , ```言語名-exec:ファイル名 )は編集、追加、削除することはできません。それ以外の文章のみを編集してください。" + + "ただし通常のコードブロック(```言語名 )の追加は可能です。" + ); + prompt.push( + " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(「修正後のドキュメントを参照してください。」など)" + ); + + let fullText = ""; + let headerParsed = false; + let chatId: string | undefined; + let targetSectionId: SectionId | undefined; + let title: string | undefined; + let contentAfterHeader = ""; + + for await (const chunk of generateContentStream( + userQuestion, + prompt.join("\n") + )) { + fullText += chunk; + + if (!headerParsed) { + const headerMatch = fullText.match(/^([^\n]+?)\n+([^\n]+?)\n+/); + if (headerMatch) { + headerParsed = true; + let secId = headerMatch[1].trim() as SectionId; + title = headerMatch[2].trim(); + + if ( + !secId || + !targetSectionContent.some((s) => s.id === secId) + ) { + secId = introSectionId(targetPath); + } + targetSectionId = secId; + + if (!title) { + throw new Error("AIからの応答にタイトルが含まれていませんでした"); + } + + const newChat = await addChat( + targetPath, + targetSectionId, + title, + [{ role: "user", content: userQuestion }], + [], + context + ); + chatId = newChat.chatId; + + onChatCreated?.(chatId, targetSectionId); + + contentAfterHeader = fullText.slice(headerMatch[0].length); + if (contentAfterHeader && onChunk) { + onChunk(contentAfterHeader); + } + } + } else { + contentAfterHeader += chunk; + if (onChunk) { + onChunk(chunk); + } + } + } + + if (!chatId || !targetSectionId || !title) { + throw new Error("AIからの応答の形式が正しくありませんでした"); + } + + const diffRegex = + /<{3,}\s*SEARCH\n*([\s\S]*?)\n*={3,}\n*([\s\S]*?)\n*>{3,}\s*REPLACE/g; + const diffRaw: CreateChatDiff[] = []; + for (const m of contentAfterHeader.matchAll(diffRegex)) { + const search = m[1]; + const replace = m[2]; + const targetSection = targetSectionContent.find((s) => + s.replacedContent.includes(search) + ); + diffRaw.push({ + search, + replace, + sectionId: targetSection?.id ?? ("" as SectionId), + targetMD5: targetSection?.md5 ?? "", + }); + } + const cleanMessage = contentAfterHeader.replace(diffRegex, "").trim(); + + await addMessagesAndDiffs( + chatId, + targetPath, + [{ role: "ai", content: cleanMessage }], + diffRaw, + context + ); + + return { + chatId, + sectionId: targetSectionId, + title, + diffRaw, + cleanMessage, + }; +} diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index d927589d..daca57b9 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -3,9 +3,19 @@ import { getAuthServer } from "./auth"; import { getDrizzle } from "./drizzle"; import { chat, diff, message, section } from "@/schema/chat"; import { and, asc, eq, exists } from "drizzle-orm"; -import { updateTag } from "next/cache"; +import { cacheLife, cacheTag, revalidateTag, updateTag } from "next/cache"; import { isCloudflare } from "./detectCloudflare"; -import { LangId, PagePath, PageSlug, SectionId } from "./docs"; +import { + getRevisionOfMarkdownSection, + LangId, + MarkdownSection, + PagePath, + PageSlug, + ReplacedRange, + SectionId, + SectionWithDiff, +} from "./docs"; +import { dateReviver } from "./dateReviver"; export interface CreateChatMessage { role: "user" | "ai" | "error"; @@ -27,9 +37,20 @@ export function cacheKeyForChat(chatId: string) { return `${CACHE_KEY_BASE}/getChatOne?chatId=${chatId}`; } -// nextjsのキャッシュのrevalidateはRouteHandlerではなくServerActionから呼ばないと正しく動作しないらしい。 -// https://github.com/vercel/next.js/issues/69064 -// そのためlib/以下の関数では直接revalidateChatを呼ばず、ServerActionの関数から呼ぶようにする。 +/** + * 指定したチャットに関連するキャッシュを即座に削除する。 + * + * 重要: nextjsのキャッシュの即時revalidate (updateTag) はServerActionでしか動作しない。 + * ServerComponentのレンダリング中や、Route Handlerの中から呼び出しても無効。 + * https://github.com/vercel/next.js/issues/69064 + * そのためこの関数の呼び出しは lib/以下の関数、route/以下のRoute Handlerの中からは行わず、 + * ServerActionの関数からのみ呼ぶようにする。 + * + * ServerAction以外でキャッシュを削除したい場面がある場合は、 + * 後述のrevalidateChatOnDemandを用いる(即座には反映されない)か、 + * クライアントに結果を返してからクライアント側で改めてrevalidateChatAction()を呼ぶか、 + * またはその両方を行う。 + */ export async function revalidateChat( chatId: string, userId: string, @@ -47,8 +68,34 @@ export async function revalidateChat( await cache.delete(cacheKeyForPage(pagePath, userId)); } } +/** + * 指定したチャットに関連するキャッシュを削除する。 + * + * Next.js 16 のrevalidateTag()を使用する。 + * Next.js 15 のrevalidateTag()とは挙動が異なるので注意。 + * + * 次のレンダリング時にstale-while-revalidateとなり、さらにその次のレンダリングから最新の内容になる? + * 即座に反映したい時は上にあるrevalidateChat()を使用 + */ +export async function revalidateChatOnDemand( + chatId: string, + userId: string, + pagePath: string | PagePath +) { + if (typeof pagePath === "string") { + const [lang, page] = pagePath.split("/") as [LangId, PageSlug]; + pagePath = { lang, page }; + } + revalidateTag(cacheKeyForChat(chatId), "max"); + revalidateTag(cacheKeyForPage(pagePath, userId), "max"); + if (isCloudflare()) { + const cache = await caches.open("chatHistory"); + await cache.delete(cacheKeyForChat(chatId)); + await cache.delete(cacheKeyForPage(pagePath, userId)); + } +} -interface Context { +export interface Context { drizzle: Awaited>; auth: Awaited>; userId?: string; @@ -279,3 +326,222 @@ export async function migrateChatUser(oldUserId: string, newUserId: string) { .set({ userId: newUserId }) .where(eq(chat.userId, oldUserId)); } + +export async function updateDiffTargetMD5( + diffId: string, + targetMD5: string, + context: Context +) { + const { drizzle, userId } = context; + if (!userId) { + throw new Error("Not authenticated"); + } + await drizzle + .update(diff) + .set({ targetMD5 }) + .where(eq(diff.id, diffId)); +} + +export function applySingleDiffToSection( + targetSection: T, + diffItem: { search: string; replace: string; chatId: string } +): boolean { + const startIndex = targetSection.replacedContent.indexOf(diffItem.search); + if (startIndex === -1) { + return false; + } + const endIndex = startIndex + diffItem.search.length; + const replaceLen = diffItem.replace.length; + const diffLen = replaceLen - diffItem.search.length; // 文字列長の増減分 + + // 1. 文字列の置換 + targetSection.replacedContent = + targetSection.replacedContent.slice(0, startIndex) + + diffItem.replace + + targetSection.replacedContent.slice(endIndex); + + // 2. 既存のハイライト範囲のズレを補正(今回の置換箇所より後ろにあるものをシフト) + targetSection.replacedRange = targetSection.replacedRange.map((h) => { + if (h.start >= endIndex) { + // 完全に後ろにある場合は単純にシフト + return { + start: h.start + diffLen, + end: h.end + diffLen, + id: h.id, + }; + } + if (h.end >= endIndex) { + return { start: h.start, end: h.end + diffLen, id: h.id }; + } + return h; + }); + + // 3. 今回の置換箇所を新たなハイライト範囲として追加 + targetSection.replacedRange.push({ + start: startIndex, + end: startIndex + replaceLen, + id: diffItem.chatId, + }); + + return true; +} + +export interface ApplyChatDiffOptions { + fallbackToPastVersion?: boolean; +} + +/** + * それぞれのセクションはmd5ハッシュでバージョン管理されており、 + * 現在のsectionデータのハッシュがsection.md5, それぞれのdiffが作られた当時のハッシュがdiff.targetMD5で得られるはずです。 + * + * もしあるdiffの適用に失敗し、かつsection.md5とdiff.targetMD5が異なる場合、 + * targetMD5が指す当時のセクションをgetRevisionOfMarkdownSection()で取得し、 + * それに対してchatDiff全体を再度適用します。 + * その場合は、そのセクションの内容の前に このドキュメントは最新ではない、最新にするにはチャットを再生成してください、 + * というalertと、再生成ボタンを表示します + * + * section.md5とtargetMD5が同じなのにdiffの適用に失敗したら、諦めます。 + * + * section.md5とtargetMD5が違うのにdiffの適用に成功したら、 + * それ以降も現在のバージョンを対象にすることができるので、 + * diff.targetMD5を現在のバージョンに更新します。 + * + * この関数はchatAreaからも呼び出されており、 + * そちらでは現在のドキュメントに対するチャット再生成の用途なので過去バージョンのドキュメントへのフォールバックは不要 + */ +export async function applyChatDiff( + splitMdContent: MarkdownSection[], + chatHistories: ChatWithMessages[], + options: ApplyChatDiffOptions = { fallbackToPastVersion: true } +): Promise { + const fallbackToPastVersion = options.fallbackToPastVersion ?? true; + + const newContent: SectionWithDiff[] = splitMdContent.map((section) => ({ + ...section, + replacedContent: section.rawContent, + replacedRange: [] as ReplacedRange[], + isOutdated: false, + outdatedDiffsToUpdate: [], + })); + + const chatDiffs = chatHistories.flatMap((chat) => chat.diff); + chatDiffs.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + const fallbackHandledSections = new Set(); + + for (const diffItem of chatDiffs) { + const targetSection = newContent.find((s) => s.id === diffItem.sectionId); + if (!targetSection) { + console.error( + `Failed to apply diff: section with id "${diffItem.sectionId}" not found` + ); + continue; + } + + if (fallbackHandledSections.has(targetSection.id)) { + // すでに過去バージョンへのフォールバック時にこのセクションの全diffが再適用されている + continue; + } + + const success = applySingleDiffToSection(targetSection, diffItem); + + if (success) { + // section.md5とtargetMD5が違うのにdiffの適用に成功したら、 + // それ以降も現在のバージョンを対象にすることができるので、diff.targetMD5を現在のバージョンに更新します。 + if ( + !targetSection.isOutdated && + targetSection.md5 && + diffItem.targetMD5 && + targetSection.md5 !== diffItem.targetMD5 + ) { + if (!targetSection.outdatedDiffsToUpdate) { + targetSection.outdatedDiffsToUpdate = []; + } + targetSection.outdatedDiffsToUpdate.push({ + chatId: diffItem.chatId, + diffId: diffItem.id, + targetMD5: targetSection.md5, + }); + } + } else { + if (targetSection.md5 === diffItem.targetMD5) { + // section.md5とtargetMD5が同じなのにdiffの適用に失敗したら、諦めます。 + console.error( + `Failed to apply diff: search string "${diffItem.search}" not found in section ${targetSection.id}` + ); + } else { + // もしあるdiffの適用に失敗し、かつsection.md5とdiff.targetMD5が異なる場合、 + // targetMD5が指す当時のセクションをgetRevisionOfMarkdownSection()で取得し、 + // それに対してchatDiff全体を再度適用します。 + if (!fallbackToPastVersion) { + console.error( + `Failed to apply diff (fallback disabled): search string "${diffItem.search}" not found in section ${targetSection.id}` + ); + continue; + } + + try { + const pastSection = await getRevisionOfMarkdownSection( + targetSection.id as SectionId, + diffItem.targetMD5 + ); + + targetSection.isOutdated = true; + targetSection.outdatedDiffsToUpdate = []; + targetSection.replacedContent = pastSection.rawContent; + targetSection.replacedRange = []; + + const sectionDiffs = chatDiffs.filter( + (d) => d.sectionId === targetSection.id + ); + for (const sDiff of sectionDiffs) { + applySingleDiffToSection(targetSection, sDiff); + } + fallbackHandledSections.add(targetSection.id); + } catch (error) { + console.error( + `Failed to fetch revision for section ${targetSection.id} (md5: ${diffItem.targetMD5}):`, + error + ); + } + } + } + } + + return newContent; +} + +/** + * チャットの取得をキャッシュする。 + * + * use cacheの仕様で、drizzleオブジェクトとauthオブジェクトは引数に渡せない。 + * 一方、use cacheの関数内でheaders()にはアクセスできない。 + * したがって、外でheaders()を使ってuserIdを取得した後、関数の中で再度drizzleを初期化しないといけない。 + * + * docsとchatの2箇所のサーバーコンポーネントで使用。ServerActionやrouteではこれではなく直接getAllChat()を呼んだ方が確実なはずです。 + */ +export async function getChatFromCache(path: PagePath, userId?: string) { + "use cache"; + cacheLife("days"); + + if (!userId) { + return []; + } + cacheTag(cacheKeyForPage(path, userId)); + + if (isCloudflare()) { + const cache = await caches.open("chatHistory"); + const cachedResponse = await cache.match(cacheKeyForPage(path, userId)); + if (cachedResponse) { + const data = JSON.parse( + await cachedResponse.text(), + dateReviver + ) as ChatWithMessages[]; + return data; + } + } + const ctx = await initContext({ userId }); + return await getAllChat(path, ctx); +} diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 86e0d69c..4b8155fd 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -95,10 +95,27 @@ export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({ */ replacedContent: z.string(), replacedRange: z.array(ReplacedRangeSchema), + /** + * セクションのMD5ハッシュが不一致で過去バージョンへの適用が行われたかどうか + */ + isOutdated: z.boolean().optional(), + /** + * 適用に成功したがtargetMD5が最新のセクションMD5と異なるため更新が必要なDiff一覧 + */ + outdatedDiffsToUpdate: z + .array( + z.object({ + chatId: z.string(), + diffId: z.string(), + targetMD5: z.string(), + }) + ) + .optional(), }); export type DynamicMarkdownSection = z.output< typeof DynamicMarkdownSectionSchema >; +export type SectionWithDiff = Omit; /** * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 diff --git a/package.json b/package.json index 867622aa..b6c529e0 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "db-docs": "tsx ./scripts/checkDocs.ts --write", "cf-preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --port 3000", "cf-deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + "test": "node --import tsx/esm --test tests/**/*.test.ts" }, "dependencies": { "@better-auth/drizzle-adapter": "^1.6.23", diff --git a/tests/chatHistory.test.ts b/tests/chatHistory.test.ts new file mode 100644 index 00000000..d98c5763 --- /dev/null +++ b/tests/chatHistory.test.ts @@ -0,0 +1,439 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import { + applyChatDiff, + applySingleDiffToSection, + cacheKeyForChat, + cacheKeyForPage, + ChatWithMessages, +} from "../app/lib/chatHistory"; +import { MarkdownSection, SectionWithDiff } from "../app/lib/docs"; + +describe("chatHistory lib (non-DB functions)", () => { + describe("cacheKeyForPage", () => { + it("should generate correct cache key for page and userId", () => { + const key = cacheKeyForPage( + { lang: "javascript" as never, page: "0-intro" as never }, + "user-123" + ); + assert.strictEqual( + key, + "https://my-code.utcode.net/chatHistory/getChat?path=javascript/0-intro&userId=user-123" + ); + }); + }); + + describe("cacheKeyForChat", () => { + it("should generate correct cache key for chatId", () => { + const key = cacheKeyForChat("chat-uuid-456"); + assert.strictEqual( + key, + "https://my-code.utcode.net/chatHistory/getChatOne?chatId=chat-uuid-456" + ); + }); + }); + + describe("applySingleDiffToSection", () => { + it("should replace content and add replacedRange when search string is found", () => { + const section: SectionWithDiff = { + file: "javascript/0-intro/1-test.md", + id: "sec-1" as never, + title: "Test Section", + level: 1, + question: [], + term: [], + rawContent: "Hello World!", + replacedContent: "Hello World!", + replacedRange: [], + md5: "hash-1", + }; + + const result = applySingleDiffToSection(section, { + search: "World", + replace: "TypeScript", + chatId: "chat-1", + }); + + assert.strictEqual(result, true); + assert.strictEqual(section.replacedContent, "Hello TypeScript!"); + assert.deepStrictEqual(section.replacedRange, [ + { + start: 6, + end: 16, + id: "chat-1", + }, + ]); + }); + + it("should return false and keep content unchanged when search string is not found", () => { + const section: SectionWithDiff = { + file: "javascript/0-intro/1-test.md", + id: "sec-1" as never, + title: "Test Section", + level: 1, + question: [], + term: [], + rawContent: "Hello World!", + replacedContent: "Hello World!", + replacedRange: [], + md5: "hash-1", + }; + + const result = applySingleDiffToSection(section, { + search: "Python", + replace: "Ruby", + chatId: "chat-2", + }); + + assert.strictEqual(result, false); + assert.strictEqual(section.replacedContent, "Hello World!"); + assert.deepStrictEqual(section.replacedRange, []); + }); + + it("should shift existing ranges when a replacement occurs before them", () => { + const section: SectionWithDiff = { + file: "test.md", + id: "sec-1" as never, + title: "Test", + level: 1, + question: [], + term: [], + rawContent: "Hello World!", + replacedContent: "Hello World!", + replacedRange: [ + { start: 6, end: 11, id: "chat-1" }, // "World" + ], + md5: "hash-1", + }; + + // Replace "Hello" (len 5) with "Greetings," (len 10) -> diffLen = +5 + const result = applySingleDiffToSection(section, { + search: "Hello", + replace: "Greetings,", + chatId: "chat-2", + }); + + assert.strictEqual(result, true); + assert.strictEqual(section.replacedContent, "Greetings, World!"); + // The original range (start: 6, end: 11) should be shifted by +5 to (start: 11, end: 16) + assert.deepStrictEqual(section.replacedRange, [ + { start: 11, end: 16, id: "chat-1" }, + { start: 0, end: 10, id: "chat-2" }, + ]); + }); + }); + + describe("applyChatDiff", () => { + it("should apply diffs when section.md5 matches diff.targetMD5", async () => { + const sections: MarkdownSection[] = [ + { + file: "test.md", + id: "sec-1" as never, + title: "Section 1", + level: 1, + question: [], + term: [], + rawContent: "Original content for section 1", + md5: "md5-v1", + }, + ]; + + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-1", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId: "sec-1" as never, + search: "Original", + replace: "Updated", + targetMD5: "md5-v1", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + const result = await applyChatDiff(sections, chatHistories); + assert.strictEqual(result[0].replacedContent, "Updated content for section 1"); + assert.strictEqual(result[0].isOutdated, false); + assert.deepStrictEqual(result[0].outdatedDiffsToUpdate, []); + }); + + it("should sort diffs by createdAt and apply them chronologically", async () => { + const sections: MarkdownSection[] = [ + { + file: "test.md", + id: "sec-1" as never, + title: "Section 1", + level: 1, + question: [], + term: [], + rawContent: "Step 0", + md5: "md5-v1", + }, + ]; + + // Pass chatHistories in reverse order to ensure sorting works + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-2", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-02T00:00:00Z"), + title: "chat 2", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-2", + chatId: "chat-2", + sectionId: "sec-1" as never, + search: "Step 1", + replace: "Step 2", + targetMD5: "md5-v1", + createdAt: new Date("2026-01-02T00:00:00Z"), + }, + ], + }, + { + chatId: "chat-1", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId: "sec-1" as never, + search: "Step 0", + replace: "Step 1", + targetMD5: "md5-v1", + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + const result = await applyChatDiff(sections, chatHistories); + assert.strictEqual(result[0].replacedContent, "Step 2"); + }); + + it("should queue outdatedDiffsToUpdate when diff succeeds on current version but targetMD5 differs", async () => { + const sections: MarkdownSection[] = [ + { + file: "test.md", + id: "sec-1" as never, + title: "Section 1", + level: 1, + question: [], + term: [], + rawContent: "Searchable Content", + md5: "md5-v2", // Current section MD5 is v2 + }, + ]; + + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-1", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId: "sec-1" as never, + search: "Searchable", + replace: "Modified", + targetMD5: "md5-v1", // Diff targetMD5 was v1 + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + const result = await applyChatDiff(sections, chatHistories); + assert.strictEqual(result[0].replacedContent, "Modified Content"); + assert.strictEqual(result[0].isOutdated, false); + assert.deepStrictEqual(result[0].outdatedDiffsToUpdate, [ + { + chatId: "chat-1", + diffId: "diff-1", + targetMD5: "md5-v2", + }, + ]); + }); + + it("should not perform fallback when section.md5 === targetMD5 and diff fails", async () => { + const sections: MarkdownSection[] = [ + { + file: "test.md", + id: "sec-1" as never, + title: "Section 1", + level: 1, + question: [], + term: [], + rawContent: "Actual Content", + md5: "md5-v1", + }, + ]; + + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-1", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId: "sec-1" as never, + search: "NonexistentString", + replace: "Replacement", + targetMD5: "md5-v1", // targetMD5 is same as section.md5 + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + const result = await applyChatDiff(sections, chatHistories); + assert.strictEqual(result[0].replacedContent, "Actual Content"); + assert.strictEqual(result[0].isOutdated, false); + assert.deepStrictEqual(result[0].outdatedDiffsToUpdate, []); + }); + + it("should not perform fallback when fallbackToPastVersion is set to false", async () => { + const sections: MarkdownSection[] = [ + { + file: "test.md", + id: "sec-1" as never, + title: "Section 1", + level: 1, + question: [], + term: [], + rawContent: "New Content Version 2", + md5: "md5-v2", + }, + ]; + + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-1", + userId: "user-1", + sectionId: "sec-1" as never, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId: "sec-1" as never, + search: "Old Content Version 1", + replace: "Diff Applied", + targetMD5: "md5-v1", // targetMD5 differs + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + const result = await applyChatDiff(sections, chatHistories, { + fallbackToPastVersion: false, + }); + + assert.strictEqual(result[0].replacedContent, "New Content Version 2"); + assert.strictEqual(result[0].isOutdated, false); + }); + + it("should fallback to past version when diff fails and targetMD5 differs", async () => { + const sectionId = "cpp-0-intro-intro" as never; + const pastMD5 = "/mArytsD75On3j08VnPJ6g=="; + + const sections: MarkdownSection[] = [ + { + file: "public/docs/cpp/0-intro/-intro.md", + id: sectionId, + title: "C++ Intro", + level: 1, + question: [], + term: [], + rawContent: "New Content Version 2", + md5: "md5-v2-different", + }, + ]; + + const chatHistories: ChatWithMessages[] = [ + { + chatId: "chat-1", + userId: "user-1", + sectionId, + createdAt: new Date("2026-01-01T00:00:00Z"), + title: "chat 1", + section: { sectionId, pagePath: "cpp/0-intro" }, + messages: [], + diff: [ + { + id: "diff-1", + chatId: "chat-1", + sectionId, + search: "Old Content Version 1", + replace: "Past Version Applied", + targetMD5: pastMD5, + createdAt: new Date("2026-01-01T00:00:00Z"), + }, + ], + }, + ]; + + // Mock fetch for getRevisionOfMarkdownSection + const originalFetch = globalThis.fetch; + mock.method(globalThis, "fetch", async (url: string | URL | Request) => { + const urlStr = url.toString(); + if (urlStr.includes("raw.githubusercontent.com")) { + const rawPastContent = `--- +id: cpp-0-intro-intro +title: C++ Intro +--- +Old Content Version 1`; + return new Response(rawPastContent, { status: 200 }); + } + return originalFetch(url); + }); + + try { + const result = await applyChatDiff(sections, chatHistories, { + fallbackToPastVersion: true, + }); + + assert.strictEqual(result[0].isOutdated, true); + assert.strictEqual(result[0].replacedContent, "Past Version Applied"); + assert.deepStrictEqual(result[0].replacedRange, [ + { start: 0, end: 20, id: "chat-1" }, + ]); + } finally { + mock.restoreAll(); + } + }); + }); +});