From 21e6bd757288bd7a6ca49109a4c8fcefe3daf42c Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:39:17 +0900 Subject: [PATCH 01/16] =?UTF-8?q?diff=E9=81=A9=E7=94=A8=E5=87=A6=E7=90=86?= =?UTF-8?q?=E3=82=92page.tsx=E3=81=AB=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これからエラーハンドリングをサーバー側で書くため --- app/(docs)/@docs/[lang]/[pageId]/page.tsx | 71 ++++++++++++++++++- .../@docs/[lang]/[pageId]/pageContent.tsx | 71 ++----------------- app/lib/docs.ts | 1 + 3 files changed, 78 insertions(+), 65 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/page.tsx b/app/(docs)/@docs/[lang]/[pageId]/page.tsx index 842e0509..961a058c 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/page.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/page.tsx @@ -12,8 +12,11 @@ import { getPagesListForLang, getTermDefinitions, LangId, + MarkdownSection, PagePath, PageSlug, + ReplacedRange, + SectionWithDiff, } from "@/lib/docs"; import { cacheLife, cacheTag } from "next/cache"; import { isCloudflare } from "@/lib/detectCloudflare"; @@ -56,6 +59,8 @@ export default async function Page({ const termDefinitions = await getTermDefinitions(lang); + const splitMdContent = applyChatDiff(sections, chatHistories); + return ( <> ({ + ...section, + replacedContent: section.rawContent, + replacedRange: [] as ReplacedRange[], + })); + const chatDiffs = chatHistories.map((chat) => chat.diff).flat(); + chatDiffs.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + for (const diff of chatDiffs) { + const targetSection = newContent.find((s) => s.id === diff.sectionId); + if (targetSection) { + const startIndex = targetSection.replacedContent.indexOf(diff.search); + if (startIndex !== -1) { + const endIndex = startIndex + diff.search.length; + const replaceLen = diff.replace.length; + const diffLen = replaceLen - diff.search.length; // 文字列長の増減分 + + // 1. 文字列の置換 + targetSection.replacedContent = + targetSection.replacedContent.slice(0, startIndex) + + diff.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: diff.chatId, + }); + } else { + // TODO: md5ハッシュを参照し過去バージョンのドキュメントへ適用を試みる + console.error( + `Failed to apply diff: search string "${diff.search}" not found in section ${targetSection.id}` + ); + } + } else { + console.error( + `Failed to apply diff: section with id "${diff.sectionId}" not found` + ); + } + } + + return newContent; +} + async function getChatFromCache(path: PagePath, userId?: string) { // チャットの取得をキャッシュする。 // use cacheの仕様で、drizzleオブジェクトとauthオブジェクトは引数に渡せない。 diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 771e81ac..c9be9855 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -9,10 +9,10 @@ import { PageTransition } from "./pageTransition"; import { DynamicMarkdownSection, LangId, - MarkdownSection, PagePath, PageSlug, SectionId, + SectionWithDiff, } from "@/lib/docs"; import { Heading } from "@/markdown/heading"; import Link from "next/link"; @@ -21,7 +21,7 @@ import { ChatWithMessages } from "@/lib/chatHistory"; import { usePagesListForLang } from "@/pagesListContext"; interface PageContentProps { - splitMdContent: MarkdownSection[]; + splitMdContent: SectionWithDiff[]; langId: LangId; pageSlug: PageSlug; path: PagePath; @@ -69,68 +69,11 @@ export function PageContent(props: PageContentProps) { }, []); const dynamicMdContent = useMemo(() => { - const newContent: DynamicMarkdownSection[] = splitMdContent.map( - (section, i) => ({ - ...section, - inView: sectionInView[i], - replacedContent: section.rawContent, - replacedRange: [], - }) - ); - const chatDiffs = chatHistories.map((chat) => chat.diff).flat(); - chatDiffs.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - for (const diff of chatDiffs) { - const targetSection = newContent.find((s) => s.id === diff.sectionId); - if (targetSection) { - const startIndex = targetSection.replacedContent.indexOf(diff.search); - if (startIndex !== -1) { - const endIndex = startIndex + diff.search.length; - const replaceLen = diff.replace.length; - const diffLen = replaceLen - diff.search.length; // 文字列長の増減分 - - // 1. 文字列の置換 - targetSection.replacedContent = - targetSection.replacedContent.slice(0, startIndex) + - diff.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: diff.chatId, - }); - } else { - // TODO: md5ハッシュを参照し過去バージョンのドキュメントへ適用を試みる - console.error( - `Failed to apply diff: search string "${diff.search}" not found in section ${targetSection.id}` - ); - } - } else { - console.error( - `Failed to apply diff: section with id "${diff.sectionId}" not found` - ); - } - } - - return newContent; - }, [splitMdContent, chatHistories, sectionInView]); + return splitMdContent.map((section, i) => ({ + ...section, + inView: sectionInView[i] ?? false, + })); + }, [splitMdContent, sectionInView]); useEffect(() => { // props.splitMdContentが変わったとき, チャットのdiffが変わった時に diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 86e0d69c..7aaec0b1 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -99,6 +99,7 @@ export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({ export type DynamicMarkdownSection = z.output< typeof DynamicMarkdownSectionSchema >; +export type SectionWithDiff = Omit; /** * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 From 8213561e62a994609107b957a4474d1d92515da6 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:04:36 +0900 Subject: [PATCH 02/16] =?UTF-8?q?=E5=86=8D=E7=94=9F=E6=88=90=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(docs)/@chat/chat/[chatId]/chatArea.tsx | 84 ++++++++- app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx | 169 ++--------------- app/(docs)/useSendChat.ts | 176 ++++++++++++++++++ 3 files changed, 271 insertions(+), 158 deletions(-) create mode 100644 app/(docs)/useSendChat.ts diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index 0f5413b0..63b62c95 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -2,12 +2,14 @@ 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"; +import { useSidebarMdContextOptional } from "@/sidebar"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -92,6 +94,41 @@ export function ChatAreaContent(props: Props) { const router = useRouter(); const streamingChatContext = useStreamingChatContext(); const isStreamingThis = streamingChatContext.chatId === chatId; + const { sendChat, isLoading: isRegenerating } = useSendChat(); + const sidebarContext = useSidebarMdContextOptional(); + + const handleRegenerate = async () => { + if (!confirm("このチャットを削除して再生成してもよろしいですか?")) { + return; + } + const firstUserMsg = chatData.messages.find((m) => m.role === "user"); + const userQuestion = firstUserMsg ? firstUserMsg.content : chatData.title; + + const sectionContent: DynamicMarkdownSection[] = + sidebarContext?.loadedPath?.lang === langId && + sidebarContext?.loadedPath?.page === pageSlug && + sidebarContext?.sidebarMdContent && + sidebarContext.sidebarMdContent.length > 0 + ? sidebarContext.sidebarMdContent + : targetSection + ? [ + { + ...targetSection, + inView: true, + replacedContent: targetSection.rawContent, + replacedRange: [], + }, + ] + : []; + + await sendChat({ + path: { lang: langId, page: pageSlug }, + userQuestion, + questionScope: "page", + sectionContent, + deleteChatOnCreated: chatId, + }); + }; return ( <> @@ -117,12 +154,55 @@ export function ChatAreaContent(props: Props) { -
+
{chatData.createdAt.toLocaleString()}
+ +
+ ); +} 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/actions/updateChatDiffTargetMD5.ts b/app/actions/updateChatDiffTargetMD5.ts new file mode 100644 index 00000000..89c03763 --- /dev/null +++ b/app/actions/updateChatDiffTargetMD5.ts @@ -0,0 +1,46 @@ +"use server"; + +import { initContext, revalidateChat, updateDiffTargetMD5 } from "@/lib/chatHistory"; +import { PagePath, PagePathSchema } from "@/lib/docs"; +import { setExtra, withServerActionInstrumentation } from "@sentry/nextjs"; +import { headers } from "next/headers"; +import { z } from "zod"; + +export async function updateChatDiffTargetMD5Action( + chatId: string, + diffId: string, + targetMD5: string, + pagePath: string | PagePath +) { + return withServerActionInstrumentation( + "updateChatDiffTargetMD5Action", + { + headers: await headers(), + recordResponse: true, + }, + async () => { + setExtra("args", { chatId, diffId, targetMD5, pagePath }); + chatId = z.uuid().parse(chatId); + diffId = z.uuid().parse(diffId); + targetMD5 = z.string().parse(targetMD5); + + 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 updateDiffTargetMD5(diffId, targetMD5, ctx); + await revalidateChat(chatId, ctx.userId, pagePath); + } + ); +} diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index a0a1308c..8957b550 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -6,6 +6,7 @@ import { and, asc, eq, exists } from "drizzle-orm"; import { cacheLife, cacheTag, updateTag } from "next/cache"; import { isCloudflare } from "./detectCloudflare"; import { + getRevisionOfMarkdownSection, LangId, MarkdownSection, PagePath, @@ -289,66 +290,167 @@ export async function migrateChatUser(oldUserId: string, newUserId: string) { .where(eq(chat.userId, oldUserId)); } -export function applyChatDiff( +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; +} + +export async function applyChatDiff( splitMdContent: MarkdownSection[], - chatHistories: ChatWithMessages[] -): SectionWithDiff[] { + 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.map((chat) => chat.diff).flat(); + + const chatDiffs = chatHistories.flatMap((chat) => chat.diff); chatDiffs.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); - for (const diff of chatDiffs) { - const targetSection = newContent.find((s) => s.id === diff.sectionId); - if (targetSection) { - const startIndex = targetSection.replacedContent.indexOf(diff.search); - if (startIndex !== -1) { - const endIndex = startIndex + diff.search.length; - const replaceLen = diff.replace.length; - const diffLen = replaceLen - diff.search.length; // 文字列長の増減分 - - // 1. 文字列の置換 - targetSection.replacedContent = - targetSection.replacedContent.slice(0, startIndex) + - diff.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: diff.chatId, + 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 { - // TODO: md5ハッシュを参照し過去バージョンのドキュメントへ適用を試みる + } + } else { + if (targetSection.md5 === diffItem.targetMD5) { + // section.md5とtargetMD5が同じなのにdiffの適用に失敗したら、諦めます。 console.error( - `Failed to apply diff: search string "${diff.search}" not found in section ${targetSection.id}` + `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 + ); + } } - } else { - console.error( - `Failed to apply diff: section with id "${diff.sectionId}" not found` - ); } } diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 7aaec0b1..4b8155fd 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -95,6 +95,22 @@ 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 From faba44c3d206b7719fa9ac4e214dd116a5c8ad19 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:12:55 +0000 Subject: [PATCH 05/16] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/node.js.yml | 15 ++ package.json | 3 +- tests/chatHistory.test.ts | 439 ++++++++++++++++++++++++++++++++++ 3 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 tests/chatHistory.test.ts 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/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..9f144fc6 --- /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", 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", 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", 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", 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", 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", 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(); + } + }); + }); +}); From fea87a2089dd7764b0b840e0dbd8889b59fc21a6 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:33:08 +0000 Subject: [PATCH 06/16] =?UTF-8?q?=E5=86=8D=E7=94=9F=E6=88=90=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=89=8A=E9=99=A4=E3=81=BE=E3=81=A7=E3=82=92=E3=82=B5?= =?UTF-8?q?=E3=83=BC=E3=83=90=E3=83=BC=E5=81=B4=E3=81=A7=E5=AE=8C=E7=B5=90?= =?UTF-8?q?=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@docs/[lang]/[pageId]/pageContent.tsx | 133 +++----- app/(docs)/useSendChat.ts | 8 +- app/api/chat/regenerate-section/route.ts | 154 +++++++++ app/api/chat/route.ts | 14 + app/lib/chatGenerator.ts | 298 ++++++++++++++++++ app/lib/chatHistory.ts | 2 +- tests/chatHistory.test.ts | 48 +-- 7 files changed, 538 insertions(+), 119 deletions(-) create mode 100644 app/api/chat/regenerate-section/route.ts create mode 100644 app/lib/chatGenerator.ts diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 1c65353d..5433f992 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -316,7 +316,7 @@ function OutdatedSectionAlert(props: { chatHistories: ChatWithMessages[]; path: PagePath; }) { - const { sectionId, splitMdContent, chatHistories, path } = props; + const { sectionId, path } = props; const [isRegenerating, setIsRegenerating] = useState(false); const [progress, setProgress] = useState<{ current: number; total: number }>({ current: 0, @@ -327,20 +327,6 @@ function OutdatedSectionAlert(props: { const router = useRouter(); const handleRegenerateSection = async () => { - const targetChats = chatHistories.filter( - (c) => - c.sectionId === sectionId || - (splitMdContent[0].id === sectionId && - splitMdContent.every((sec) => c.sectionId !== sec.id)) - ); - - targetChats.sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - - if (targetChats.length === 0) return; - if ( !confirm( "このセクションの全チャットを最新のドキュメントに対して再生成しますか?" @@ -350,82 +336,55 @@ function OutdatedSectionAlert(props: { } setIsRegenerating(true); - setProgress({ current: 0, total: targetChats.length }); + setProgress({ current: 0, total: 0 }); try { - let currentSectionContent: DynamicMarkdownSection[] = splitMdContent.map( - (s) => ({ - ...s, - inView: false, - replacedContent: s.rawContent, - replacedRange: [], - isOutdated: false, - }) - ); - - for (let i = 0; i < targetChats.length; i++) { - const oldChat = targetChats[i]; - setProgress({ current: i + 1, total: targetChats.length }); - - const firstUserMsg = oldChat.messages.find((m) => m.role === "user"); - const userQuestion = firstUserMsg ? firstUserMsg.content : oldChat.title; - - const response = await fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path, - userQuestion, - questionScope: "page", - sectionContent: currentSectionContent, - replOutputs, - files, - execResults, - }), - }); - - if (!response.ok) { - throw new Error(`Chat generation failed: ${response.status}`); - } - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let newChatId: string | null = null; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; + const response = await fetch("/api/chat/regenerate-section", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + sectionId, + replOutputs, + files, + execResults, + }), + }); - for (const line of lines) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as ChatStreamEvent; - if (event.type === "chat") { - newChatId = event.chatId; - await deleteChatAction(oldChat.chatId); - } - } catch (e) { - captureException(e); - } - } - } + if (!response.ok) { + throw new Error(`API route error: ${response.status}`); + } - if (newChatId) { - const newChatData = await getChatOneAction(newChatId); - if (newChatData && newChatData.diff.length > 0) { - for (const d of newChatData.diff) { - const targetSec = currentSectionContent.find( - (sec) => sec.id === d.sectionId - ); - if (targetSec) { - applySingleDiffToSection(targetSec, d); - } + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + 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 { + type: "progress" | "done" | "error"; + current?: number; + total?: number; + message?: string; + }; + if (event.type === "progress" && event.current && event.total) { + setProgress({ current: event.current, total: event.total }); + } else if (event.type === "done") { + router.refresh(); + } else if (event.type === "error") { + throw new Error(event.message ?? "Error occurred during regeneration"); } + } catch (e) { + captureException(e); } } } @@ -456,7 +415,7 @@ function OutdatedSectionAlert(props: { {isRegenerating ? ( <> - 再生成中 ({progress.current}/{progress.total}) + 再生成中 {progress.total > 0 ? `(${progress.current}/${progress.total})` : ""} ) : ( "再生成" diff --git a/app/(docs)/useSendChat.ts b/app/(docs)/useSendChat.ts index 5b534e52..f51d51f9 100644 --- a/app/(docs)/useSendChat.ts +++ b/app/(docs)/useSendChat.ts @@ -8,7 +8,6 @@ import { DynamicMarkdownSection, PagePath } from "@/lib/docs"; import { ChatStreamEvent } from "@/api/chat/route"; import { revalidateChatAction } from "@/actions/revalidateChat"; import { useStreamingChatContext } from "./streamingChatContext"; -import { deleteChatAction } from "@/actions/deleteChat"; export interface SendChatParams { path: PagePath; @@ -102,12 +101,7 @@ export function useSendChat() { // 2. 新チャットの再検証 await revalidateChatAction(event.chatId, event.pagePath); - // 3. 旧チャットの削除(指定されている場合) - if (deleteChatOnCreated) { - await deleteChatAction(deleteChatOnCreated); - } - - // 4. セクションのスクロール + // 3. セクションのスクロール if (event.pagePath === `${path.lang}/${path.page}`) { document.getElementById(event.sectionId)?.scrollIntoView({ behavior: "smooth", diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts new file mode 100644 index 00000000..25b586d7 --- /dev/null +++ b/app/api/chat/regenerate-section/route.ts @@ -0,0 +1,154 @@ +import { NextRequest } from "next/server"; +import { + applySingleDiffToSection, + deleteChat, + getAllChat, + initContext, + revalidateChat, +} 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" } + | { type: "error"; message: string }; + +export async function POST(request: NextRequest) { + const context = await initContext(); + if (!context.userId) { + return new Response("Unauthorized", { status: 401 }); + } + const userId = context.userId; + + 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" }); + controller.close(); + return; + } + + let currentSectionContent: DynamicMarkdownSection[] = rawSections.map( + (s) => ({ + ...s, + inView: false, + replacedContent: s.rawContent, + replacedRange: [], + isOutdated: false, + }) + ); + + for (let i = 0; i < targetChats.length; i++) { + const oldChat = targetChats[i]; + send({ type: "progress", current: i + 1, total: targetChats.length }); + + 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, + }); + + // 2. Delete old chat from DB + await deleteChat(oldChat.chatId, context); + + // 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, + }); + } + } + } + + // Revalidate cache for the page + const firstChatId = targetChats[0]?.chatId ?? ""; + await revalidateChat(firstChatId, userId, path); + + send({ type: "done" }); + 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..3b48f8be 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,6 +4,7 @@ import { addChat, addMessagesAndDiffs, CreateChatDiff, + deleteChat, initContext, } from "@/lib/chatHistory"; import { @@ -26,6 +27,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 +54,7 @@ export async function POST(request: NextRequest) { userQuestion, questionScope, sectionContent, + deleteChatOnCreated, replOutputs, files, execResults, @@ -404,6 +407,17 @@ 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 + ); + } + } + 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 8957b550..7c5e4b25 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -58,7 +58,7 @@ export async function revalidateChat( } } -interface Context { +export interface Context { drizzle: Awaited>; auth: Awaited>; userId?: string; diff --git a/tests/chatHistory.test.ts b/tests/chatHistory.test.ts index 9f144fc6..d98c5763 100644 --- a/tests/chatHistory.test.ts +++ b/tests/chatHistory.test.ts @@ -40,8 +40,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Test Section", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Hello World!", replacedContent: "Hello World!", replacedRange: [], @@ -71,8 +71,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Test Section", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Hello World!", replacedContent: "Hello World!", replacedRange: [], @@ -96,8 +96,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Test", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Hello World!", replacedContent: "Hello World!", replacedRange: [ @@ -131,8 +131,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Section 1", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Original content for section 1", md5: "md5-v1", }, @@ -145,7 +145,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-01T00:00:00Z"), title: "chat 1", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -174,8 +174,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Section 1", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Step 0", md5: "md5-v1", }, @@ -189,7 +189,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-02T00:00:00Z"), title: "chat 2", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -209,7 +209,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-01T00:00:00Z"), title: "chat 1", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -236,8 +236,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Section 1", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Searchable Content", md5: "md5-v2", // Current section MD5 is v2 }, @@ -250,7 +250,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-01T00:00:00Z"), title: "chat 1", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -285,8 +285,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Section 1", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "Actual Content", md5: "md5-v1", }, @@ -299,7 +299,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-01T00:00:00Z"), title: "chat 1", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -328,8 +328,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: "sec-1" as never, title: "Section 1", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "New Content Version 2", md5: "md5-v2", }, @@ -342,7 +342,7 @@ describe("chatHistory lib (non-DB functions)", () => { sectionId: "sec-1" as never, createdAt: new Date("2026-01-01T00:00:00Z"), title: "chat 1", - section: { sectionId: "sec-1", pagePath: "js/page1" }, + section: { sectionId: "sec-1" as never, pagePath: "js/page1" }, messages: [], diff: [ { @@ -376,8 +376,8 @@ describe("chatHistory lib (non-DB functions)", () => { id: sectionId, title: "C++ Intro", level: 1, - question: "", - term: "", + question: [], + term: [], rawContent: "New Content Version 2", md5: "md5-v2-different", }, From 83f70f0b0522ec7bb4c18214f6effd78757e0a9b Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:15:36 +0000 Subject: [PATCH 07/16] =?UTF-8?q?revalidate=E3=82=92=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88=E3=81=A7=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=99=E3=82=88=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@docs/[lang]/[pageId]/pageContent.tsx | 23 +++++++++++-------- app/(docs)/useSendChat.ts | 6 +++++ app/api/chat/regenerate-section/route.ts | 17 +++++++------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 5433f992..9d851903 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -22,10 +22,11 @@ import { usePagesListForLang } from "@/pagesListContext"; import { DaisyWarningIcon } from "@/daisyAlertIcon"; import { useEmbedContext } from "@/terminal/embedContext"; import { useRouter } from "next/navigation"; -import { deleteChatAction } from "@/actions/deleteChat"; +import { revalidateChatAction } from "@/actions/revalidateChat"; import { updateChatDiffTargetMD5Action } from "@/actions/updateChatDiffTargetMD5"; import { getChatOneAction } from "@/actions/getChat"; import { ChatStreamEvent } from "@/api/chat/route"; +import { RegenerateStreamEvent } from "@/api/chat/regenerate-section/route"; import { captureException } from "@sentry/nextjs"; interface PageContentProps { @@ -370,18 +371,22 @@ function OutdatedSectionAlert(props: { for (const line of lines) { if (!line.trim()) continue; try { - const event = JSON.parse(line) as { - type: "progress" | "done" | "error"; - current?: number; - total?: number; - message?: string; - }; - if (event.type === "progress" && event.current && event.total) { + const event = JSON.parse(line) as RegenerateStreamEvent; + if (event.type === "progress") { setProgress({ current: event.current, total: event.total }); } else if (event.type === "done") { + const allChatIds = [ + ...(event.deletedChatIds ?? []), + ...(event.createdChatIds ?? []), + ]; + for (const chatId of allChatIds) { + await revalidateChatAction(chatId, path); + } router.refresh(); } else if (event.type === "error") { - throw new Error(event.message ?? "Error occurred during regeneration"); + throw new Error( + event.message ?? "Error occurred during regeneration" + ); } } catch (e) { captureException(e); diff --git a/app/(docs)/useSendChat.ts b/app/(docs)/useSendChat.ts index f51d51f9..b58662b6 100644 --- a/app/(docs)/useSendChat.ts +++ b/app/(docs)/useSendChat.ts @@ -100,6 +100,9 @@ export function useSendChat() { // 2. 新チャットの再検証 await revalidateChatAction(event.chatId, event.pagePath); + if (deleteChatOnCreated) { + await revalidateChatAction(deleteChatOnCreated, event.pagePath); + } // 3. セクションのスクロール if (event.pagePath === `${path.lang}/${path.page}`) { @@ -123,6 +126,9 @@ export function useSendChat() { if (chatId) { await revalidateChatAction(chatId, chatPagePath); } + if (deleteChatOnCreated) { + await revalidateChatAction(deleteChatOnCreated, chatPagePath); + } streamingChatContext.finishStreaming(); router.refresh(); } else if (event.type === "error") { diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 25b586d7..69ac1ff3 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -4,7 +4,6 @@ import { deleteChat, getAllChat, initContext, - revalidateChat, } from "@/lib/chatHistory"; import { DynamicMarkdownSection, @@ -29,7 +28,7 @@ const RegenerateSectionSchema = z.object({ export type RegenerateStreamEvent = | { type: "progress"; current: number; total: number } - | { type: "done" } + | { type: "done"; deletedChatIds: string[]; createdChatIds: string[] } | { type: "error"; message: string }; export async function POST(request: NextRequest) { @@ -37,7 +36,6 @@ export async function POST(request: NextRequest) { if (!context.userId) { return new Response("Unauthorized", { status: 401 }); } - const userId = context.userId; const parseResult = RegenerateSectionSchema.safeParse(await request.json()); if (!parseResult.success) { @@ -70,7 +68,7 @@ export async function POST(request: NextRequest) { ); if (targetChats.length === 0) { - send({ type: "done" }); + send({ type: "done", deletedChatIds: [], createdChatIds: [] }); controller.close(); return; } @@ -85,8 +83,12 @@ export async function POST(request: NextRequest) { }) ); + const deletedChatIds: string[] = []; + const createdChatIds: string[] = []; + for (let i = 0; i < targetChats.length; i++) { const oldChat = targetChats[i]; + deletedChatIds.push(oldChat.chatId); send({ type: "progress", current: i + 1, total: targetChats.length }); const firstUserMsg = oldChat.messages.find((m) => m.role === "user"); @@ -102,6 +104,7 @@ export async function POST(request: NextRequest) { execResults, context, }); + createdChatIds.push(result.chatId); // 2. Delete old chat from DB await deleteChat(oldChat.chatId, context); @@ -121,11 +124,7 @@ export async function POST(request: NextRequest) { } } - // Revalidate cache for the page - const firstChatId = targetChats[0]?.chatId ?? ""; - await revalidateChat(firstChatId, userId, path); - - send({ type: "done" }); + send({ type: "done", deletedChatIds, createdChatIds }); controller.close(); } catch (error: unknown) { captureException(error); From 5aaef881a9d2bee219754bb17c7aed4373a204d5 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:14:37 +0900 Subject: [PATCH 08/16] fix --- .../@docs/[lang]/[pageId]/pageContent.tsx | 80 ++++++++++++++----- app/(docs)/useSendChat.ts | 1 + app/api/chat/regenerate-section/route.ts | 2 +- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 9d851903..9f2e62d0 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -129,7 +129,11 @@ export function PageContent(props: PageContentProps) { {dynamicMdContent.map((section, index) => (
{ sectionRefs.current[index] = el; @@ -405,27 +409,61 @@ function OutdatedSectionAlert(props: { }; return ( -
-
- - - このドキュメントは最新ではない、最新にするにはチャットを再生成してください - +
+
+
+ 新しいバージョンのドキュメントがあります。更新するにはチャットを再生成する必要があります。 +
+
- + {isRegenerating && ( + + )}
); } diff --git a/app/(docs)/useSendChat.ts b/app/(docs)/useSendChat.ts index b58662b6..e22b0b98 100644 --- a/app/(docs)/useSendChat.ts +++ b/app/(docs)/useSendChat.ts @@ -53,6 +53,7 @@ export function useSendChat() { replOutputs, files, execResults, + deleteChatOnCreated, }), }); } catch (e) { diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 69ac1ff3..0783d2e5 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -89,7 +89,7 @@ export async function POST(request: NextRequest) { for (let i = 0; i < targetChats.length; i++) { const oldChat = targetChats[i]; deletedChatIds.push(oldChat.chatId); - send({ type: "progress", current: i + 1, total: targetChats.length }); + send({ type: "progress", current: i, total: targetChats.length }); const firstUserMsg = oldChat.messages.find((m) => m.role === "user"); const userQuestion = firstUserMsg ? firstUserMsg.content : oldChat.title; From f4cdc7b75d0c1d31377059837f2aea710ecbacc1 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:17:30 +0900 Subject: [PATCH 09/16] fix lint --- app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx | 5 +---- app/api/chat/regenerate-section/route.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 9f2e62d0..322c6cef 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -17,15 +17,12 @@ import { import { Heading } from "@/markdown/heading"; import Link from "next/link"; import { useChatId } from "@/(docs)/chatAreaState"; -import { applySingleDiffToSection, ChatWithMessages } from "@/lib/chatHistory"; +import { ChatWithMessages } from "@/lib/chatHistory"; import { usePagesListForLang } from "@/pagesListContext"; -import { DaisyWarningIcon } from "@/daisyAlertIcon"; import { useEmbedContext } from "@/terminal/embedContext"; import { useRouter } from "next/navigation"; import { revalidateChatAction } from "@/actions/revalidateChat"; import { updateChatDiffTargetMD5Action } from "@/actions/updateChatDiffTargetMD5"; -import { getChatOneAction } from "@/actions/getChat"; -import { ChatStreamEvent } from "@/api/chat/route"; import { RegenerateStreamEvent } from "@/api/chat/regenerate-section/route"; import { captureException } from "@sentry/nextjs"; diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 0783d2e5..28f2fd4d 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -73,7 +73,7 @@ export async function POST(request: NextRequest) { return; } - let currentSectionContent: DynamicMarkdownSection[] = rawSections.map( + const currentSectionContent: DynamicMarkdownSection[] = rawSections.map( (s) => ({ ...s, inView: false, From 773c995374923e7a457aae3b7a3c9ce33c2c1b3a Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:02:31 +0000 Subject: [PATCH 10/16] =?UTF-8?q?revalidateTag()=E3=82=92=E4=BD=BF?= =?UTF-8?q?=E3=81=88=E3=81=B0=E5=BF=85=E3=81=9A=E3=81=97=E3=82=82ServerAct?= =?UTF-8?q?ion=E3=82=92=E7=B5=8C=E7=94=B1=E3=81=99=E3=82=8B=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E3=81=AF=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(docs)/@docs/[lang]/[pageId]/page.tsx | 13 +++++++++ .../@docs/[lang]/[pageId]/pageContent.tsx | 23 --------------- app/lib/chatHistory.ts | 28 ++++++++++++++++++- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/page.tsx b/app/(docs)/@docs/[lang]/[pageId]/page.tsx index 0704657b..1e0c03c3 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/page.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/page.tsx @@ -5,6 +5,8 @@ import { applyChatDiff, getChatFromCache, initContext, + revalidateChatOnDemand, + updateDiffTargetMD5, } from "@/lib/chatHistory"; import { getMarkdownSections, @@ -53,6 +55,17 @@ export default async function Page({ const splitMdContent = await applyChatDiff(sections, chatHistories); + if (context.userId) { + for (const sec of splitMdContent) { + if (sec.outdatedDiffsToUpdate && sec.outdatedDiffsToUpdate.length > 0) { + for (const item of sec.outdatedDiffsToUpdate) { + await updateDiffTargetMD5(item.diffId, item.targetMD5, context); + await revalidateChatOnDemand(item.chatId, context.userId, path); + } + } + } + } + return ( <> ()); - useEffect(() => { - for (const section of splitMdContent) { - if ( - section.outdatedDiffsToUpdate && - section.outdatedDiffsToUpdate.length > 0 - ) { - for (const item of section.outdatedDiffsToUpdate) { - if (!updatedDiffIds.current.has(item.diffId)) { - updatedDiffIds.current.add(item.diffId); - void updateChatDiffTargetMD5Action( - item.chatId, - item.diffId, - item.targetMD5, - path - ); - } - } - } - } - }, [splitMdContent, path]); - const [isFormVisible, setIsFormVisible] = useState(false); return ( diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 7c5e4b25..42d969f9 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -3,7 +3,7 @@ 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 { cacheLife, cacheTag, updateTag } from "next/cache"; +import { cacheLife, cacheTag, revalidateTag, updateTag } from "next/cache"; import { isCloudflare } from "./detectCloudflare"; import { getRevisionOfMarkdownSection, @@ -58,6 +58,32 @@ export async function revalidateChat( } } +/** + * 指定したチャットに関連するキャッシュを削除する。 + * + * Next.js 16 のrevalidateTag()を使用する。 + * Next.js 15 のrevalidateTag()とは挙動が異なるので注意。 + * + * 反映タイミングは次のレンダリング時になる。即座に反映したい時は上にある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)); + } +} + export interface Context { drizzle: Awaited>; auth: Awaited>; From 4f49ba008b68ae55a193ce0da0b7ea5d55a50160 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:10:40 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=83=89=E3=81=A7=E3=82=82=E9=87=8D=E8=A4=87?= =?UTF-8?q?=E3=81=97=E3=81=A6revalidate=E3=82=92=E3=81=97=E3=81=A6?= =?UTF-8?q?=E3=81=8A=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/updateChatDiffTargetMD5.ts | 46 ------------------------ app/api/chat/regenerate-section/route.ts | 5 +++ app/api/chat/route.ts | 7 ++++ app/lib/chatHistory.ts | 25 +++++++++---- 4 files changed, 30 insertions(+), 53 deletions(-) delete mode 100644 app/actions/updateChatDiffTargetMD5.ts diff --git a/app/actions/updateChatDiffTargetMD5.ts b/app/actions/updateChatDiffTargetMD5.ts deleted file mode 100644 index 89c03763..00000000 --- a/app/actions/updateChatDiffTargetMD5.ts +++ /dev/null @@ -1,46 +0,0 @@ -"use server"; - -import { initContext, revalidateChat, updateDiffTargetMD5 } from "@/lib/chatHistory"; -import { PagePath, PagePathSchema } from "@/lib/docs"; -import { setExtra, withServerActionInstrumentation } from "@sentry/nextjs"; -import { headers } from "next/headers"; -import { z } from "zod"; - -export async function updateChatDiffTargetMD5Action( - chatId: string, - diffId: string, - targetMD5: string, - pagePath: string | PagePath -) { - return withServerActionInstrumentation( - "updateChatDiffTargetMD5Action", - { - headers: await headers(), - recordResponse: true, - }, - async () => { - setExtra("args", { chatId, diffId, targetMD5, pagePath }); - chatId = z.uuid().parse(chatId); - diffId = z.uuid().parse(diffId); - targetMD5 = z.string().parse(targetMD5); - - 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 updateDiffTargetMD5(diffId, targetMD5, ctx); - await revalidateChat(chatId, ctx.userId, pagePath); - } - ); -} diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 28f2fd4d..dc48b1be 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -4,6 +4,7 @@ import { deleteChat, getAllChat, initContext, + revalidateChatOnDemand, } from "@/lib/chatHistory"; import { DynamicMarkdownSection, @@ -122,6 +123,10 @@ export async function POST(request: NextRequest) { }); } } + + // クライアントでもrevalidateChatActionを呼ぶが、一応こちらでもrevalidateしておく + await revalidateChatOnDemand(oldChat.chatId, context.userId!, path); + await revalidateChatOnDemand(result.chatId, context.userId!, path); } send({ type: "done", deletedChatIds, createdChatIds }); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 3b48f8be..803b59cf 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -6,6 +6,7 @@ import { CreateChatDiff, deleteChat, initContext, + revalidateChatOnDemand, } from "@/lib/chatHistory"; import { DynamicMarkdownSectionSchema, @@ -418,6 +419,12 @@ export async function POST(request: NextRequest) { } } + // クライアントでも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/chatHistory.ts b/app/lib/chatHistory.ts index 42d969f9..9da4ba0b 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -37,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, @@ -57,14 +68,14 @@ export async function revalidateChat( await cache.delete(cacheKeyForPage(pagePath, userId)); } } - /** * 指定したチャットに関連するキャッシュを削除する。 - * + * * Next.js 16 のrevalidateTag()を使用する。 * Next.js 15 のrevalidateTag()とは挙動が異なるので注意。 - * - * 反映タイミングは次のレンダリング時になる。即座に反映したい時は上にあるrevalidateChat()を使用 + * + * 次のレンダリング時にstale-while-revalidateとなり、さらにその次のレンダリングから最新の内容になる? + * 即座に反映したい時は上にあるrevalidateChat()を使用 */ export async function revalidateChatOnDemand( chatId: string, From bab21bdf8f412e2e494be622ad63c0b872511b68 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:18:36 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(docs)/useSendChat.ts | 3 +++ app/api/chat/regenerate-section/route.ts | 8 ++++++++ app/lib/chatHistory.ts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/app/(docs)/useSendChat.ts b/app/(docs)/useSendChat.ts index e22b0b98..cc6719b3 100644 --- a/app/(docs)/useSendChat.ts +++ b/app/(docs)/useSendChat.ts @@ -18,6 +18,9 @@ export interface SendChatParams { onSuccess?: () => void; } +/** + * チャットの作成・既存チャットの再生成で使う、クライアント側のチャットストリーミング描画・revalidate・ルーティングの関数 + */ export function useSendChat() { const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index dc48b1be..414730db 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -32,6 +32,14 @@ export type RegenerateStreamEvent = | { 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) { diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 9da4ba0b..daca57b9 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -390,6 +390,25 @@ 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[], From 5cb10e295a2e310a5d2c170e3f2879022b9a3941 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:09:58 +0000 Subject: [PATCH 13/16] fix: remove unused imports in pageContent.tsx --- app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 48007608..7beb8036 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -22,8 +22,6 @@ import { usePagesListForLang } from "@/pagesListContext"; import { useEmbedContext } from "@/terminal/embedContext"; import { useRouter } from "next/navigation"; import { revalidateChatAction } from "@/actions/revalidateChat"; -import { getChatOneAction } from "@/actions/getChat"; -import { ChatStreamEvent } from "@/api/chat/route"; import { RegenerateStreamEvent } from "@/api/chat/regenerate-section/route"; import { captureException } from "@sentry/nextjs"; From 63c501533e9eebd8da6fb26e88e08a635e9d48b5 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:10:13 +0000 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=E3=82=BB=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=86=8D=E7=94=9F=E6=88=90=E6=99=82=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E8=B1=A1=E5=A4=96=E3=83=81=E3=83=A3=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AEdiff=E3=82=92=E5=88=9D=E6=9C=9F=E9=81=A9=E7=94=A8?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat/regenerate-section/route.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 414730db..53dd1a36 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -1,5 +1,5 @@ -import { NextRequest } from "next/server"; import { + applyChatDiff, applySingleDiffToSection, deleteChat, getAllChat, @@ -82,13 +82,19 @@ export async function POST(request: NextRequest) { return; } - const currentSectionContent: DynamicMarkdownSection[] = rawSections.map( + 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, - replacedContent: s.rawContent, - replacedRange: [], - isOutdated: false, }) ); From bbd9f8ce5400de93010cdcc37062d61957241dad Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:10:37 +0000 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=E3=82=BB=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=86=8D=E7=94=9F=E6=88=90=E6=99=82=E3=81=AB?= =?UTF-8?q?=E5=80=8B=E5=88=A5=E3=81=AE=E3=83=81=E3=83=A3=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=8C=E7=99=BA=E7=94=9F=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=82=82=E5=BE=8C=E7=B6=9A=E5=87=A6=E7=90=86=E3=82=92?= =?UTF-8?q?=E7=B6=9A=E8=A1=8C=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat/regenerate-section/route.ts | 73 +++++++++++++----------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 53dd1a36..8ccc703c 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -103,44 +103,49 @@ export async function POST(request: NextRequest) { for (let i = 0; i < targetChats.length; i++) { const oldChat = targetChats[i]; - deletedChatIds.push(oldChat.chatId); send({ type: "progress", current: i, total: targetChats.length }); - 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); - - // 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, - }); + 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); + // クライアントでも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 }); From 96161f94d1a3f0657ebbd72c82369a8893a5dfb0 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:11:17 +0000 Subject: [PATCH 16/16] fix: import NextRequest in regenerate-section/route.ts --- app/api/chat/regenerate-section/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/chat/regenerate-section/route.ts b/app/api/chat/regenerate-section/route.ts index 8ccc703c..7efcc12d 100644 --- a/app/api/chat/regenerate-section/route.ts +++ b/app/api/chat/regenerate-section/route.ts @@ -1,3 +1,4 @@ +import { NextRequest } from "next/server"; import { applyChatDiff, applySingleDiffToSection,