From ac302c9ffd1844f938a26e1d0ef6f7bb0455b66e Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:41:47 +0900 Subject: [PATCH 1/5] =?UTF-8?q?AI=E3=81=8C=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AEdiff=E3=81=A8=E3=81=97?= =?UTF-8?q?=E3=81=A6=E5=9B=9E=E7=AD=94=E3=82=92=E5=87=BA=E5=8A=9B=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/chatActions.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts index 9efcb89..48a25e6 100644 --- a/app/actions/chatActions.ts +++ b/app/actions/chatActions.ts @@ -66,6 +66,13 @@ export async function askAI(params: ChatParams): Promise { `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` ); prompt.push(``); + prompt.push( + `質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。` + ); + prompt.push( + `質問内容とドキュメントの内容の関連性が深く、比較的長めの解説をしたい場合、またはドキュメントへの補足がしたい場合は、そちらの形式での回答を検討してください。` + ); + prompt.push(``); prompt.push(`# ドキュメント`); prompt.push(``); for (const section of sectionContent) { @@ -157,6 +164,27 @@ export async function askAI(params: ChatParams): Promise { prompt.push( " - 水平線(---)はシステムが区切りとして認識するので、ユーザーへの回答中に水平線を使用することはできません。" ); + prompt.push( + "- ユーザーへのメッセージの最後の行の次には水平線 --- を出力してください。" + ); + prompt.push( + "- それ以降の行に、ドキュメントの一部を改訂したい場合はその差分を" + ); + prompt.push("<<<<<<< SEARCH"); + prompt.push("修正したい元の文章の塊(一字一句違わずに)"); + prompt.push("======="); + prompt.push("修正後の新しい文章の塊"); + prompt.push(">>>>>>> REPLACE"); + prompt.push("の形式で出力してください。"); + prompt.push( + " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" + ); + prompt.push( + " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" + ); + prompt.push( + " - セクションid、セクション見出し、およびコードブロックの内側を編集することはできません。それ以外の文章のみを編集してください。" + ); console.log(prompt); try { @@ -170,6 +198,14 @@ export async function askAI(params: ChatParams): Promise { targetSectionId = introSectionId(path); } const responseMessage = text.split(/-{3,}/)[1].trim(); + const diffMatch = text + .split(/-{3,}/)[2] + .matchAll( + /<{3,}\s*SEARCH\n([\s\S]*?)\n={3,}\n([\s\S]*?)\n>{3,}\s*REPLACE/g + ); + const diff: { search: string; replace: string }[] = diffMatch + ? Array.from(diffMatch, (m) => ({ search: m[1], replace: m[2] })) + : []; const newChat = await addChat(path, targetSectionId, [ { role: "user", content: userQuestion }, { role: "ai", content: responseMessage }, From ff3513083e8ddc66a56271ccaa5197475a1682be Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:54:51 +0900 Subject: [PATCH 2/5] =?UTF-8?q?diff=E3=82=92=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AB=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/chatActions.ts | 35 +- app/lib/chatHistory.ts | 28 +- app/schema/chat.ts | 23 ++ drizzle/0004_busy_orphan.sql | 9 + drizzle/meta/0004_snapshot.json | 547 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + 6 files changed, 635 insertions(+), 14 deletions(-) create mode 100644 drizzle/0004_busy_orphan.sql create mode 100644 drizzle/meta/0004_snapshot.json diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts index 48a25e6..4dfb9c2 100644 --- a/app/actions/chatActions.ts +++ b/app/actions/chatActions.ts @@ -4,7 +4,7 @@ import { generateContent } from "./gemini"; import { DynamicMarkdownSection } from "../[lang]/[pageId]/pageContent"; import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; -import { addChat, ChatWithMessages } from "@/lib/chatHistory"; +import { addChat, ChatWithMessages, CreateChatDiff } from "@/lib/chatHistory"; import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; type ChatResult = @@ -198,18 +198,35 @@ export async function askAI(params: ChatParams): Promise { targetSectionId = introSectionId(path); } const responseMessage = text.split(/-{3,}/)[1].trim(); - const diffMatch = text + const diffRaw: CreateChatDiff[] = []; + for (const m of text .split(/-{3,}/)[2] .matchAll( /<{3,}\s*SEARCH\n([\s\S]*?)\n={3,}\n([\s\S]*?)\n>{3,}\s*REPLACE/g + )) { + const search = m[1]; + const replace = m[2]; + const targetSection = sectionContent.find((s) => + s.rawContent.includes(search) ); - const diff: { search: string; replace: string }[] = diffMatch - ? Array.from(diffMatch, (m) => ({ search: m[1], replace: m[2] })) - : []; - const newChat = await addChat(path, targetSectionId, [ - { role: "user", content: userQuestion }, - { role: "ai", content: responseMessage }, - ]); + if (targetSection) { + diffRaw.push({ + search, + replace, + sectionId: targetSection.id, + targetMD5: targetSection.md5, + }); + } + } + const newChat = await addChat( + path, + targetSectionId, + [ + { role: "user", content: userQuestion }, + { role: "ai", content: responseMessage }, + ], + diffRaw + ); return { error: null, chat: newChat, diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 4be495d..3a0cf52 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { getAuthServer } from "./auth"; import { getDrizzle } from "./drizzle"; -import { chat, message, section } from "@/schema/chat"; +import { chat, diff, message, section } from "@/schema/chat"; import { and, asc, eq, exists } from "drizzle-orm"; import { Auth } from "better-auth"; import { revalidateTag, unstable_cacheLife } from "next/cache"; @@ -15,6 +15,12 @@ export interface CreateChatMessage { role: "user" | "ai" | "error"; content: string; } +export interface CreateChatDiff { + search: string; + replace: string; + sectionId: SectionId; + targetMD5: string; +} // cacheに使うキーで、実際のURLではない const CACHE_KEY_BASE = "https://my-code.utcode.net/chatHistory"; @@ -57,6 +63,7 @@ export async function addChat( path: PagePath, sectionId: SectionId, messages: CreateChatMessage[], + diffRaw: CreateChatDiff[], context?: Partial ) { const { drizzle, userId } = await initContext(context); @@ -82,6 +89,13 @@ export async function addChat( ) .returning(); + const chatDiffs = await drizzle.insert(diff).values( + diffRaw.map((d) => ({ + chatId: newChat.chatId, + ...d, + })) + ); + revalidateTag(cacheKeyForPage(path, userId)); if (isCloudflare()) { const cache = await caches.open("chatHistory"); @@ -98,6 +112,7 @@ export async function addChat( pagePath: `${path.lang}/${path.page}`, }, messages: chatMessages, + diff: chatDiffs, }; } @@ -119,10 +134,12 @@ export async function getChat( drizzle .select() .from(section) - .where(and( - eq(section.sectionId, chat.sectionId), - eq(section.pagePath, `${path.lang}/${path.page}`), - )) + .where( + and( + eq(section.sectionId, chat.sectionId), + eq(section.pagePath, `${path.lang}/${path.page}`) + ) + ) ) ), with: { @@ -130,6 +147,7 @@ export async function getChat( messages: { orderBy: [asc(message.createdAt)], }, + diff: true, }, orderBy: [asc(chat.createdAt)], }); diff --git a/app/schema/chat.ts b/app/schema/chat.ts index 4aec2c5..cf26426 100644 --- a/app/schema/chat.ts +++ b/app/schema/chat.ts @@ -21,16 +21,28 @@ export const message = pgTable("message", { createdAt: timestamp("createdAt").notNull().defaultNow(), }); +export const diff = pgTable("diff", { + id: uuid("id").primaryKey().defaultRandom(), + chatId: uuid("chatId").notNull(), + search: text("search").notNull(), + replace: text("replace").notNull(), + sectionId: text("sectionId").notNull(), + targetMD5: text("targetMD5").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + export const chatRelations = relations(chat, ({ many, one }) => ({ messages: many(message), section: one(section, { fields: [chat.sectionId], references: [section.sectionId], }), + diff: many(diff), })); export const sectionRelations = relations(chat, ({ many }) => ({ chat: many(chat), + // diff: many(diff), })); export const messageRelations = relations(message, ({ one }) => ({ @@ -39,3 +51,14 @@ export const messageRelations = relations(message, ({ one }) => ({ references: [chat.chatId], }), })); + +export const diffRelations = relations(diff, ({ one }) => ({ + // section: one(section, { + // fields: [diff.sectionId], + // references: [section.sectionId], + // }), + chat: one(chat, { + fields: [diff.chatId], + references: [chat.chatId], + }), +})); diff --git a/drizzle/0004_busy_orphan.sql b/drizzle/0004_busy_orphan.sql new file mode 100644 index 0000000..214b831 --- /dev/null +++ b/drizzle/0004_busy_orphan.sql @@ -0,0 +1,9 @@ +CREATE TABLE "diff" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chatId" uuid NOT NULL, + "search" text NOT NULL, + "replace" text NOT NULL, + "sectionId" text NOT NULL, + "targetMD5" text NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..2935d32 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,547 @@ +{ + "id": "b738a961-0a1a-4898-8323-95b072abfca7", + "prevId": "192e6df7-07c4-4913-8572-9dc40b41050a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sectionId": { + "name": "sectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diff": { + "name": "diff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search": { + "name": "search", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replace": { + "name": "replace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sectionId": { + "name": "sectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetMD5": { + "name": "targetMD5", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "sectionId": { + "name": "sectionId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pagePath": { + "name": "pagePath", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5cc136f..2e4b098 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1772895009610, "tag": "0003_thin_ben_grimm", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773327252896, + "tag": "0004_busy_orphan", + "breakpoints": true } ] } \ No newline at end of file From 62c9a7d05cbc64d4284326e42516cb4dc415aa97 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:41:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?diff=E3=82=92=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AB=E9=81=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lang]/[pageId]/pageContent.tsx | 68 +++++++++++++++++++++-------- app/actions/chatActions.ts | 2 +- app/lib/chatHistory.ts | 15 ++++--- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/app/[lang]/[pageId]/pageContent.tsx b/app/[lang]/[pageId]/pageContent.tsx index 2e07690..bd5b116 100644 --- a/app/[lang]/[pageId]/pageContent.tsx +++ b/app/[lang]/[pageId]/pageContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { ChatForm } from "./chatForm"; import { Heading, StyledMarkdown } from "./markdown"; import { useChatHistoryContext } from "./chatHistory"; @@ -14,9 +14,18 @@ import { PagePath, } from "@/lib/docs"; -// MarkdownSectionに追加で、ユーザーが今そのセクションを読んでいるかどうか、などの動的な情報を持たせる +/** + * MarkdownSectionに追加で、動的な情報を持たせる + */ export type DynamicMarkdownSection = MarkdownSection & { + /** + * ユーザーが今そのセクションを読んでいるかどうか + */ inView: boolean; + /** + * チャットの会話を元にAIが書き換えた後の内容 + */ + replacedContent: string; }; interface PageContentProps { @@ -31,25 +40,52 @@ export function PageContent(props: PageContentProps) { const { setSidebarMdContent } = useSidebarMdContext(); const { splitMdContent, pageEntry, path } = props; - // SSR用のローカルstate - const [dynamicMdContent, setDynamicMdContent] = useState< - DynamicMarkdownSection[] - >( - splitMdContent.map((section) => ({ - ...section, - inView: false, - })) - ); + const { chatHistories } = useChatHistoryContext(); - useEffect(() => { - // props.splitMdContentが変わったときにローカルstateとcontextの両方を更新 + const initDynamicMdContent = useCallback(() => { const newContent = splitMdContent.map((section) => ({ ...section, inView: false, + replacedContent: section.rawContent, })); + 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) { + if (targetSection.replacedContent.includes(diff.search)) { + targetSection.replacedContent = targetSection.replacedContent.replace( + diff.search, + diff.replace + ); + } 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]); + + // SSR用のローカルstate + const [dynamicMdContent, setDynamicMdContent] = useState< + DynamicMarkdownSection[] + >(() => initDynamicMdContent()); + + useEffect(() => { + // props.splitMdContentが変わったとき, チャットのdiffが変わった時に + // ローカルstateとcontextの両方を更新 + const newContent = initDynamicMdContent(); setDynamicMdContent(newContent); setSidebarMdContent(path, newContent); - }, [splitMdContent, path, setSidebarMdContent]); + }, [initDynamicMdContent, path, setSidebarMdContent]); const sectionRefs = useRef>([]); // sectionRefsの長さをsplitMdContentに合わせる @@ -87,8 +123,6 @@ export function PageContent(props: PageContentProps) { const [isFormVisible, setIsFormVisible] = useState(false); - const { chatHistories } = useChatHistoryContext(); - return (
{/* ドキュメントのコンテンツ */} - +
{/* 右側に表示するチャット履歴欄 */} diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts index 4dfb9c2..3006949 100644 --- a/app/actions/chatActions.ts +++ b/app/actions/chatActions.ts @@ -77,7 +77,7 @@ export async function askAI(params: ChatParams): Promise { prompt.push(``); for (const section of sectionContent) { prompt.push(`[セクションid: ${section.id}]`); - prompt.push(section.rawContent.trim()); + prompt.push(section.replacedContent.trim()); prompt.push(``); } prompt.push(``); diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 3a0cf52..09e475f 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -89,12 +89,15 @@ export async function addChat( ) .returning(); - const chatDiffs = await drizzle.insert(diff).values( - diffRaw.map((d) => ({ - chatId: newChat.chatId, - ...d, - })) - ); + const chatDiffs = await drizzle + .insert(diff) + .values( + diffRaw.map((d) => ({ + chatId: newChat.chatId, + ...d, + })) + ) + .returning(); revalidateTag(cacheKeyForPage(path, userId)); if (isCloudflare()) { From fe7d0e83f7e2afe0844d28032cbf9502d4ed7c91 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:56:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?diff=E3=82=92=E3=83=8F=E3=82=A4=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=88=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lang]/[pageId]/markdown.tsx | 31 ++++++++-- app/[lang]/[pageId]/pageContent.tsx | 63 ++++++++++++++++---- app/markdown/multiHighlight.ts | 92 +++++++++++++++++++++++++++++ package-lock.json | 3 + package.json | 3 + 5 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 app/markdown/multiHighlight.ts diff --git a/app/[lang]/[pageId]/markdown.tsx b/app/[lang]/[pageId]/markdown.tsx index a9d574a..870b1e1 100644 --- a/app/[lang]/[pageId]/markdown.tsx +++ b/app/[lang]/[pageId]/markdown.tsx @@ -8,14 +8,24 @@ import { JSX, ReactNode } from "react"; import { langConstants, MarkdownLang } from "@my-code/runtime/languages"; import { ReplTerminal } from "@/terminal/repl"; import { StyledSyntaxHighlighter } from "./styledSyntaxHighlighter"; +import clsx from "clsx"; +import { remarkMultiHighlight, ReplacedRange } from "@/markdown/multiHighlight"; -export function StyledMarkdown({ content }: { content: string }) { +export function StyledMarkdown(props: { + content: string; + replacedRange?: ReplacedRange[]; +}) { return ( - {content} + {props.content} ); } @@ -50,6 +60,17 @@ const components: Components = { code: ({ node, className, ref, style, ...props }) => ( ), + ins: ({ node, className, ...props }) => ( + + ), }; export function Heading({ @@ -151,9 +172,7 @@ function CodeComponent({ ); } else { // inline - return ( - {String(props.children || "").replace(/\n$/, "")} - ); + return {props.children}; } } diff --git a/app/[lang]/[pageId]/pageContent.tsx b/app/[lang]/[pageId]/pageContent.tsx index bd5b116..21746e3 100644 --- a/app/[lang]/[pageId]/pageContent.tsx +++ b/app/[lang]/[pageId]/pageContent.tsx @@ -13,11 +13,12 @@ import { PageEntry, PagePath, } from "@/lib/docs"; +import { ReplacedRange } from "@/markdown/multiHighlight"; /** * MarkdownSectionに追加で、動的な情報を持たせる */ -export type DynamicMarkdownSection = MarkdownSection & { +export interface DynamicMarkdownSection extends MarkdownSection { /** * ユーザーが今そのセクションを読んでいるかどうか */ @@ -26,7 +27,8 @@ export type DynamicMarkdownSection = MarkdownSection & { * チャットの会話を元にAIが書き換えた後の内容 */ replacedContent: string; -}; + replacedRange: ReplacedRange[]; +} interface PageContentProps { splitMdContent: MarkdownSection[]; @@ -43,21 +45,53 @@ export function PageContent(props: PageContentProps) { const { chatHistories } = useChatHistoryContext(); const initDynamicMdContent = useCallback(() => { - const newContent = splitMdContent.map((section) => ({ - ...section, - inView: false, - replacedContent: section.rawContent, - })); + const newContent: DynamicMarkdownSection[] = splitMdContent.map( + (section) => ({ + ...section, + inView: false, + 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) { - if (targetSection.replacedContent.includes(diff.search)) { - targetSection.replacedContent = targetSection.replacedContent.replace( - diff.search, - diff.replace - ); + 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( @@ -144,7 +178,10 @@ export function PageContent(props: PageContentProps) { }} > {/* ドキュメントのコンテンツ */} - +
{/* 右側に表示するチャット履歴欄 */} diff --git a/app/markdown/multiHighlight.ts b/app/markdown/multiHighlight.ts new file mode 100644 index 0000000..3a78757 --- /dev/null +++ b/app/markdown/multiHighlight.ts @@ -0,0 +1,92 @@ +import { visit } from "unist-util-visit"; +import type { Plugin } from "unified"; +import type { Root, PhrasingContent } from "mdast"; + +export interface ReplacedRange { + start: number; + end: number; + id: string; +} +export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = ( + replacedRange?: ReplacedRange[] +) => { + return (tree) => { + visit(tree, "text", (node, index, parent) => { + const nodeStart = node.position?.start?.offset; + if ( + nodeStart === undefined || + index === undefined || + parent === undefined + ) + return; + + const textLen = node.value.length; + const nodeEnd = nodeStart + textLen; + + // 1. このテキストノードに被るハイライトが1つもなければスキップ (最適化) + if (!replacedRange) return; + const hasOverlap = replacedRange.some( + (hl) => hl.start < nodeEnd && hl.end > nodeStart + ); + if (!hasOverlap) return; + + // 2. テキストを分割するための「境界インデックス(相対位置)」を収集 + let boundaries = [0, textLen]; // ノードの最初と最後は必ず入れる + + replacedRange.forEach((hl) => { + const relStart = hl.start - nodeStart; + const relEnd = hl.end - nodeStart; + + // ノードの途中にある境界だけを追加 + if (relStart > 0 && relStart < textLen) boundaries.push(relStart); + if (relEnd > 0 && relEnd < textLen) boundaries.push(relEnd); + }); + + // 3. 境界インデックスの重複を排除し、昇順にソートする + boundaries = Array.from(new Set(boundaries)).sort((a, b) => a - b); + + const newNodes: PhrasingContent[] = []; + + // 4. 隣り合う境界ごとに区間(セグメント)を切り出す + for (let i = 0; i < boundaries.length - 1; i++) { + const startIdx = boundaries[i]; + const endIdx = boundaries[i + 1]; + + const textValue = node.value.slice(startIdx, endIdx); + const absStart = nodeStart + startIdx; + const absEnd = nodeStart + endIdx; + + // 5. この区間を完全に包含しているハイライトIDをすべて抽出 + const activeIds = replacedRange + .filter((hl) => hl.start <= absStart && hl.end >= absEnd) + .map((hl) => hl.id); + + if (activeIds.length > 0) { + // 該当するハイライトがある場合、クラス名を複数付与してカスタムノード化 + const classNames = activeIds; + + // カスタムタイプ('highlight')は標準のmdastに存在しないため、型アサーションでエラーを回避します + newNodes.push({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: "highlight" as any, + data: { + hName: "ins", + // AST(hast)の仕様上、classNameは配列で渡すとスペース区切りで展開されます + hProperties: { className: classNames }, + }, + children: [{ type: "text", value: textValue }], + }); + } else { + // どのハイライトにも含まれない場合は通常のテキスト + newNodes.push({ type: "text", value: textValue }); + } + } + + // 6. 元のテキストノードを分割したノード群に置き換える + parent.children.splice(index, 1, ...newNodes); + + // 追加したノード分インデックスを進める + return index + newNodes.length; + }); + }; +}; diff --git a/package-lock.json b/package-lock.json index 2ee3504..c1c4b21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "remark-gfm": "^4.0.1", "remark-remove-comments": "^1.1.1", "swr": "^2.3.6", + "unist-util-visit": "^5.1.0", "zod": "^4.0.17" }, "devDependencies": { @@ -44,6 +45,7 @@ "@pyodide/webpack-plugin": "^1.4.0", "@tailwindcss/postcss": "^4", "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/pako": "^2.0.4", @@ -63,6 +65,7 @@ "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "5.9.3", + "unified": "^11.0.5", "wrangler": "^4.27.0" } }, diff --git a/package.json b/package.json index af5b2dc..f526d99 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "remark-gfm": "^4.0.1", "remark-remove-comments": "^1.1.1", "swr": "^2.3.6", + "unist-util-visit": "^5.1.0", "zod": "^4.0.17" }, "devDependencies": { @@ -53,6 +54,7 @@ "@pyodide/webpack-plugin": "^1.4.0", "@tailwindcss/postcss": "^4", "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", "@types/mocha": "^10.0.10", "@types/node": "^20", "@types/pako": "^2.0.4", @@ -72,6 +74,7 @@ "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "5.9.3", + "unified": "^11.0.5", "wrangler": "^4.27.0" } } From 34509f883abe95f5d5b4fa52d3f8a769a72583fa Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:03:17 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=AF=E3=83=80?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E9=96=A2=E9=80=A3=E3=81=AE=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92app/markdown=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/[lang]/[pageId]/markdown.tsx | 185 ------------------ app/[lang]/[pageId]/pageContent.tsx | 3 +- app/markdown/codeBlock.tsx | 92 +++++++++ app/markdown/heading.tsx | 27 +++ app/markdown/markdown.tsx | 61 ++++++ .../{multiHighlight.ts => multiHighlight.tsx} | 21 ++ .../styledSyntaxHighlighter.tsx | 0 app/terminal/page.tsx | 2 +- app/terminal/repl.tsx | 9 +- 9 files changed, 210 insertions(+), 190 deletions(-) delete mode 100644 app/[lang]/[pageId]/markdown.tsx create mode 100644 app/markdown/codeBlock.tsx create mode 100644 app/markdown/heading.tsx create mode 100644 app/markdown/markdown.tsx rename app/markdown/{multiHighlight.ts => multiHighlight.tsx} (85%) rename app/{[lang]/[pageId] => markdown}/styledSyntaxHighlighter.tsx (100%) diff --git a/app/[lang]/[pageId]/markdown.tsx b/app/[lang]/[pageId]/markdown.tsx deleted file mode 100644 index 870b1e1..0000000 --- a/app/[lang]/[pageId]/markdown.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import Markdown, { Components, ExtraProps } from "react-markdown"; -import remarkGfm from "remark-gfm"; -import removeComments from "remark-remove-comments"; -import remarkCjkFriendly from "remark-cjk-friendly"; -import { EditorComponent } from "@/terminal/editor"; -import { ExecFile } from "@/terminal/exec"; -import { JSX, ReactNode } from "react"; -import { langConstants, MarkdownLang } from "@my-code/runtime/languages"; -import { ReplTerminal } from "@/terminal/repl"; -import { StyledSyntaxHighlighter } from "./styledSyntaxHighlighter"; -import clsx from "clsx"; -import { remarkMultiHighlight, ReplacedRange } from "@/markdown/multiHighlight"; - -export function StyledMarkdown(props: { - content: string; - replacedRange?: ReplacedRange[]; -}) { - return ( - - {props.content} - - ); -} - -// TailwindCSSがh1などのタグのスタイルを消してしまうので、手動でスタイルを指定する必要がある -const components: Components = { - h1: ({ children }) => {children}, - h2: ({ children }) => {children}, - h3: ({ children }) => {children}, - h4: ({ children }) => {children}, - h5: ({ children }) => {children}, - h6: ({ children }) => {children}, - p: ({ node, ...props }) =>

, - ul: ({ node, ...props }) => ( -