diff --git a/app/[lang]/[pageId]/markdown.tsx b/app/[lang]/[pageId]/markdown.tsx
deleted file mode 100644
index a9d574a..0000000
--- a/app/[lang]/[pageId]/markdown.tsx
+++ /dev/null
@@ -1,166 +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";
-
-export function StyledMarkdown({ content }: { content: string }) {
- return (
-
- {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 }) => (
-
- ),
- ol: ({ node, ...props }) => (
-
- ),
- li: ({ node, ...props }) => ,
- a: ({ node, ...props }) => ,
- strong: ({ node, ...props }) => (
-
- ),
- table: ({ node, ...props }) => (
-
- ),
- hr: ({ node, ...props }) =>
,
- pre: ({ node, ...props }) => props.children,
- code: ({ node, className, ref, style, ...props }) => (
-
- ),
-};
-
-export function Heading({
- level,
- children,
-}: {
- level: number;
- children: ReactNode;
-}) {
- switch (level) {
- case 0:
- return null;
- case 1:
- return {children}
;
- case 2:
- return {children}
;
- case 3:
- return {children}
;
- case 4:
- return {children}
;
- case 5:
- // TODO: これ以下は4との差がない (全体的に大きくする必要がある?)
- return {children}
;
- case 6:
- return {children}
;
- }
-}
-
-function CodeComponent({
- node,
- className,
- ref,
- style,
- ...props
-}: JSX.IntrinsicElements["code"] & ExtraProps) {
- const match = /^language-(\w+)(-repl|-exec|-readonly)?\:?(.+)?$/.exec(
- className || ""
- );
- if (match) {
- const language = langConstants(match[1] as MarkdownLang | undefined);
- if (match[2] === "-exec" && match[3]) {
- /*
- ```python-exec:main.py
- hello, world!
- ```
- ↓
- ---------------------------
- [▶ 実行] `python main.py`
- hello, world!
- ---------------------------
- */
- if (language.runtime) {
- return (
-
- );
- }
- } else if (match[2] === "-repl") {
- // repl付きの言語指定
- if (!match[3]) {
- console.error(
- `${match[1]}-repl without terminal id! content: ${String(props.children).slice(0, 20)}...`
- );
- }
- if (language.runtime) {
- return (
-
- );
- }
- } else if (match[3]) {
- // ファイル名指定がある場合、ファイルエディター
- return (
-
- );
- }
- return (
-
- {String(props.children || "").replace(/\n$/, "")}
-
- );
- } else if (String(props.children).includes("\n")) {
- // 言語指定なしコードブロック
- return (
-
- {String(props.children || "").replace(/\n$/, "")}
-
- );
- } else {
- // inline
- return (
- {String(props.children || "").replace(/\n$/, "")}
- );
- }
-}
-
-export function InlineCode({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/app/[lang]/[pageId]/pageContent.tsx b/app/[lang]/[pageId]/pageContent.tsx
index 2e07690..8d3a85f 100644
--- a/app/[lang]/[pageId]/pageContent.tsx
+++ b/app/[lang]/[pageId]/pageContent.tsx
@@ -1,8 +1,8 @@
"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 { StyledMarkdown } from "@/markdown/markdown";
import { useChatHistoryContext } from "./chatHistory";
import { useSidebarMdContext } from "@/sidebar";
import clsx from "clsx";
@@ -13,11 +13,23 @@ import {
PageEntry,
PagePath,
} from "@/lib/docs";
+import { ReplacedRange } from "@/markdown/multiHighlight";
+import { Heading } from "@/markdown/heading";
-// MarkdownSectionに追加で、ユーザーが今そのセクションを読んでいるかどうか、などの動的な情報を持たせる
-export type DynamicMarkdownSection = MarkdownSection & {
+/**
+ * MarkdownSectionに追加で、動的な情報を持たせる
+ */
+export interface DynamicMarkdownSection extends MarkdownSection {
+ /**
+ * ユーザーが今そのセクションを読んでいるかどうか
+ */
inView: boolean;
-};
+ /**
+ * チャットの会話を元にAIが書き換えた後の内容
+ */
+ replacedContent: string;
+ replacedRange: ReplacedRange[];
+}
interface PageContentProps {
splitMdContent: MarkdownSection[];
@@ -31,25 +43,84 @@ export function PageContent(props: PageContentProps) {
const { setSidebarMdContent } = useSidebarMdContext();
const { splitMdContent, pageEntry, path } = props;
+ const { chatHistories } = useChatHistoryContext();
+
+ const initDynamicMdContent = useCallback(() => {
+ 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) {
+ 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]);
+
// SSR用のローカルstate
const [dynamicMdContent, setDynamicMdContent] = useState<
DynamicMarkdownSection[]
- >(
- splitMdContent.map((section) => ({
- ...section,
- inView: false,
- }))
- );
+ >(() => initDynamicMdContent());
useEffect(() => {
- // props.splitMdContentが変わったときにローカルstateとcontextの両方を更新
- const newContent = splitMdContent.map((section) => ({
- ...section,
- inView: false,
- }));
+ // 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 +158,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 158ba24..4a0d3aa 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 =
@@ -66,11 +66,18 @@ export async function askAI(params: ChatParams): Promise
{
`質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。`
);
prompt.push(``);
+ prompt.push(
+ `質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。`
+ );
+ prompt.push(
+ `質問内容とドキュメントの内容の関連性が深く、比較的長めの解説をしたい場合、またはドキュメントへの補足がしたい場合は、そちらの形式での回答を検討してください。`
+ );
+ prompt.push(``);
prompt.push(`# ドキュメント`);
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(``);
@@ -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,10 +198,35 @@ export async function askAI(params: ChatParams): Promise {
targetSectionId = introSectionId(path);
}
const responseMessage = text.split(/\n-{3,}\n/)[1].trim();
- const newChat = await addChat(path, targetSectionId, [
- { role: "user", content: userQuestion },
- { role: "ai", content: responseMessage },
- ]);
+ const diffRaw: CreateChatDiff[] = [];
+ for (const m of text
+ .split(/\n-{3,}\n/)[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)
+ );
+ 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..09e475f 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,16 @@ export async function addChat(
)
.returning();
+ const chatDiffs = await drizzle
+ .insert(diff)
+ .values(
+ diffRaw.map((d) => ({
+ chatId: newChat.chatId,
+ ...d,
+ }))
+ )
+ .returning();
+
revalidateTag(cacheKeyForPage(path, userId));
if (isCloudflare()) {
const cache = await caches.open("chatHistory");
@@ -98,6 +115,7 @@ export async function addChat(
pagePath: `${path.lang}/${path.page}`,
},
messages: chatMessages,
+ diff: chatDiffs,
};
}
@@ -119,10 +137,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 +150,7 @@ export async function getChat(
messages: {
orderBy: [asc(message.createdAt)],
},
+ diff: true,
},
orderBy: [asc(chat.createdAt)],
});
diff --git a/app/markdown/codeBlock.tsx b/app/markdown/codeBlock.tsx
new file mode 100644
index 0000000..6695b50
--- /dev/null
+++ b/app/markdown/codeBlock.tsx
@@ -0,0 +1,92 @@
+import { EditorComponent } from "@/terminal/editor";
+import { ExecFile } from "@/terminal/exec";
+import { ReplTerminal } from "@/terminal/repl";
+import { langConstants, MarkdownLang } from "@my-code/runtime/languages";
+import { JSX, ReactNode } from "react";
+import { ExtraProps } from "react-markdown";
+import { StyledSyntaxHighlighter } from "./styledSyntaxHighlighter";
+
+export function AutoCodeBlock({
+ node,
+ className,
+ ref,
+ style,
+ ...props
+}: JSX.IntrinsicElements["code"] & ExtraProps) {
+ const match = /^language-(\w+)(-repl|-exec|-readonly)?\:?(.+)?$/.exec(
+ className || ""
+ );
+ if (match) {
+ const language = langConstants(match[1] as MarkdownLang | undefined);
+ if (match[2] === "-exec" && match[3]) {
+ /*
+ ```python-exec:main.py
+ hello, world!
+ ```
+ ↓
+ ---------------------------
+ [▶ 実行] `python main.py`
+ hello, world!
+ ---------------------------
+ */
+ if (language.runtime) {
+ return (
+
+ );
+ }
+ } else if (match[2] === "-repl") {
+ // repl付きの言語指定
+ if (!match[3]) {
+ console.error(
+ `${match[1]}-repl without terminal id! content: ${String(props.children).slice(0, 20)}...`
+ );
+ }
+ if (language.runtime) {
+ return (
+
+ );
+ }
+ } else if (match[3]) {
+ // ファイル名指定がある場合、ファイルエディター
+ return (
+
+ );
+ }
+ return (
+
+ {String(props.children || "").replace(/\n$/, "")}
+
+ );
+ } else if (String(props.children).includes("\n")) {
+ // 言語指定なしコードブロック
+ return (
+
+ {String(props.children || "").replace(/\n$/, "")}
+
+ );
+ } else {
+ // inline
+ return {props.children};
+ }
+}
+
+export function InlineCode({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/markdown/heading.tsx b/app/markdown/heading.tsx
new file mode 100644
index 0000000..031b4c7
--- /dev/null
+++ b/app/markdown/heading.tsx
@@ -0,0 +1,27 @@
+import { ReactNode } from "react";
+
+export function Heading({
+ level,
+ children,
+}: {
+ level: number;
+ children: ReactNode;
+}) {
+ switch (level) {
+ case 0:
+ return null;
+ case 1:
+ return {children}
;
+ case 2:
+ return {children}
;
+ case 3:
+ return {children}
;
+ case 4:
+ return {children}
;
+ case 5:
+ // TODO: これ以下は4との差がない (全体的に大きくする必要がある?)
+ return {children}
;
+ case 6:
+ return {children}
;
+ }
+}
diff --git a/app/markdown/markdown.tsx b/app/markdown/markdown.tsx
new file mode 100644
index 0000000..2f77ddf
--- /dev/null
+++ b/app/markdown/markdown.tsx
@@ -0,0 +1,61 @@
+import Markdown, { Components } from "react-markdown";
+import remarkGfm from "remark-gfm";
+import removeComments from "remark-remove-comments";
+import remarkCjkFriendly from "remark-cjk-friendly";
+import {
+ MultiHighlightTag,
+ remarkMultiHighlight,
+ ReplacedRange,
+} from "./multiHighlight";
+import { Heading } from "./heading";
+import { AutoCodeBlock } from "./codeBlock";
+
+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 }) => (
+
+ ),
+ ol: ({ node, ...props }) => (
+
+ ),
+ li: ({ node, ...props }) => ,
+ a: ({ node, ...props }) => ,
+ strong: ({ node, ...props }) => (
+
+ ),
+ table: ({ node, ...props }) => (
+
+ ),
+ hr: ({ node, ...props }) =>
,
+ pre: ({ node, ...props }) => props.children,
+ code: AutoCodeBlock,
+ ins: MultiHighlightTag,
+};
diff --git a/app/markdown/multiHighlight.tsx b/app/markdown/multiHighlight.tsx
new file mode 100644
index 0000000..6504669
--- /dev/null
+++ b/app/markdown/multiHighlight.tsx
@@ -0,0 +1,113 @@
+import { visit } from "unist-util-visit";
+import type { Plugin } from "unified";
+import type { Root, PhrasingContent } from "mdast";
+import { JSX } from "react";
+import { ExtraProps } from "react-markdown";
+import clsx from "clsx";
+
+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;
+ });
+ };
+};
+
+export function MultiHighlightTag({
+ node,
+ className,
+ ...props
+}: JSX.IntrinsicElements["ins"] & ExtraProps) {
+ return (
+
+ );
+}
diff --git a/app/[lang]/[pageId]/styledSyntaxHighlighter.tsx b/app/markdown/styledSyntaxHighlighter.tsx
similarity index 100%
rename from app/[lang]/[pageId]/styledSyntaxHighlighter.tsx
rename to app/markdown/styledSyntaxHighlighter.tsx
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/app/terminal/page.tsx b/app/terminal/page.tsx
index e522293..97ad2fc 100644
--- a/app/terminal/page.tsx
+++ b/app/terminal/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Heading } from "@/[lang]/[pageId]/markdown";
+import { Heading } from "@/markdown/heading";
import "mocha/mocha.css";
import { Fragment, useEffect, useState } from "react";
import { langConstants, RuntimeLang } from "@my-code/runtime/languages";
diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx
index b800dd7..c85f4d7 100644
--- a/app/terminal/repl.tsx
+++ b/app/terminal/repl.tsx
@@ -16,13 +16,16 @@ import type { Terminal } from "@xterm/xterm";
import { useEmbedContext } from "./embedContext";
import { LangConstants } from "@my-code/runtime/languages";
import clsx from "clsx";
-import { InlineCode } from "@/[lang]/[pageId]/markdown";
-import { emptyMutex, ReplCommand, ReplOutput } from "@my-code/runtime/interface";
+import { InlineCode } from "@/markdown/codeBlock";
+import {
+ emptyMutex,
+ ReplCommand,
+ ReplOutput,
+} from "@my-code/runtime/interface";
import { useRuntime } from "@my-code/runtime/context";
import { MinMaxButton, Modal } from "./modal";
import { StopButtonContent } from "./exec";
-
export function writeOutput(
term: Terminal,
output: ReplOutput,
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
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"
}
}