diff --git a/app/[docs_id]/chatForm.tsx b/app/[docs_id]/chatForm.tsx index feec78d..d2ad7f7 100644 --- a/app/[docs_id]/chatForm.tsx +++ b/app/[docs_id]/chatForm.tsx @@ -1,42 +1,50 @@ "use client"; -import { useState, FormEvent } from "react"; -import clsx from "clsx"; +import { useState, FormEvent, useEffect } from "react"; import { askAI } from "@/app/actions/chatActions"; -import { StyledMarkdown } from "./markdown"; -import { useChatHistory, type Message } from "../hooks/useChathistory"; import useSWR from "swr"; -import { getQuestionExample } from "../actions/questionExample"; +import { + getQuestionExample, + QuestionExampleParams, +} from "../actions/questionExample"; import { getLanguageName } from "../pagesList"; -import { ReplCommand, ReplOutput } from "../terminal/repl"; +import { DynamicMarkdownSection } from "./pageContent"; +import { useEmbedContext } from "../terminal/embedContext"; +import { ChatMessage, useChatHistoryContext } from "./chatHistory"; interface ChatFormProps { + docs_id: string; documentContent: string; - sectionId: string; - replOutputs: ReplCommand[]; - fileContents: Array<{ - name: string; - content: string; - }>; - execResults: Record; + sectionContent: DynamicMarkdownSection[]; + close: () => void; } export function ChatForm({ + docs_id, documentContent, - sectionId, - replOutputs, - fileContents, - execResults, + sectionContent, + close, }: ChatFormProps) { - const [messages, updateChatHistory] = useChatHistory(sectionId); + // const [messages, updateChatHistory] = useChatHistory(sectionId); const [inputValue, setInputValue] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [isFormVisible, setIsFormVisible] = useState(false); - const lang = getLanguageName(sectionId); + const { addChat } = useChatHistoryContext(); + + const lang = getLanguageName(docs_id); + + const { files, replOutputs, execResults } = useEmbedContext(); + + const documentContentInView = sectionContent + .filter((s) => s.inView) + .map((s) => s.rawContent) + .join("\n\n"); const { data: exampleData, error: exampleError } = useSWR( // 質問フォームを開いたときだけで良い - isFormVisible ? { lang, documentContent } : null, + { + lang, + documentContent: documentContentInView, + } satisfies QuestionExampleParams, getQuestionExample, { // リクエストは古くても構わないので1回でいい @@ -51,13 +59,17 @@ export function ChatForm({ // 質問フォームを開くたびにランダムに選び直し、 // exampleData[Math.floor(exampleChoice * exampleData.length)] を採用する const [exampleChoice, setExampleChoice] = useState(0); // 0〜1 + useEffect(() => { + if (exampleChoice === 0) { + setExampleChoice(Math.random()); + } + }, [exampleChoice]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setIsLoading(true); - const userMessage: Message = { sender: "user", text: inputValue }; - updateChatHistory([userMessage]); + const userMessage: ChatMessage = { sender: "user", text: inputValue }; let userQuestion = inputValue; if (!userQuestion && exampleData) { @@ -69,148 +81,89 @@ export function ChatForm({ const result = await askAI({ userQuestion, - documentContent: documentContent, + documentContent, + sectionContent, replOutputs, - fileContents, + files, execResults, }); if (result.error) { - const errorMessage: Message = { - sender: "ai", + const errorMessage: ChatMessage = { + sender: "error", text: `エラー: ${result.error}`, - isError: true, }; - updateChatHistory([userMessage, errorMessage]); + console.log(result.error); + // TODO: ユーザーに表示 } else { - const aiMessage: Message = { sender: "ai", text: result.response }; - updateChatHistory([userMessage, aiMessage]); + const aiMessage: ChatMessage = { sender: "ai", text: result.response }; + const chatId = addChat(result.targetSectionId, [userMessage, aiMessage]); + // TODO: chatIdが指す対象の回答にフォーカス setInputValue(""); + close(); } setIsLoading(false); }; - const handleClearHistory = () => { - updateChatHistory([]); - }; - return ( - <> - {isFormVisible && ( -
+
+ -
-
setInputValue(e.target.value)} + disabled={isLoading} + > +
+
+
+ -
-
- -
-
-
- )} - {!isFormVisible && ( - - )} - - {messages.length > 0 && ( -
-
-

AIとのチャット

- -
- {messages.map((msg, index) => ( -
-
- -
-
- ))} -
- )} - - {isLoading && ( -
- AIが考え中です… + 閉じる + +
+
+
- )} - + + ); } diff --git a/app/[docs_id]/chatHistory.tsx b/app/[docs_id]/chatHistory.tsx new file mode 100644 index 0000000..65d95ce --- /dev/null +++ b/app/[docs_id]/chatHistory.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; + +export interface ChatMessage { + sender: "user" | "ai" | "error"; + text: string; +} + +export interface IChatHistoryContext { + chatHistories: Record>; + addChat: (sectionId: string, messages: ChatMessage[]) => string; + updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void; +} +const ChatHistoryContext = createContext(null); +export function useChatHistoryContext() { + const context = useContext(ChatHistoryContext); + if (!context) { + throw new Error( + "useChatHistoryContext must be used within a ChatHistoryProvider" + ); + } + return context; +} + +export function ChatHistoryProvider({ children }: { children: ReactNode }) { + const [chatHistories, setChatHistories] = useState< + Record> + >({}); + useEffect(() => { + // Load chat histories from localStorage on mount + const chatHistories: Record> = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith("chat/") && key.split("/").length === 3) { + const savedHistory = localStorage.getItem(key); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, sectionId, chatId] = key.split("/"); + if (savedHistory) { + if (!chatHistories[sectionId]) { + chatHistories[sectionId] = {}; + } + chatHistories[sectionId][chatId] = JSON.parse(savedHistory); + } + } + } + setChatHistories(chatHistories); + }, []); + + const addChat = (sectionId: string, messages: ChatMessage[]): string => { + const chatId = Date.now().toString(); + const newChatHistories = { ...chatHistories }; + if (!newChatHistories[sectionId]) { + newChatHistories[sectionId] = {}; + } + newChatHistories[sectionId][chatId] = messages; + setChatHistories(newChatHistories); + localStorage.setItem( + `chat/${sectionId}/${chatId}`, + JSON.stringify(messages) + ); + return chatId; + }; + const updateChat = ( + sectionId: string, + chatId: string, + message: ChatMessage + ) => { + const newChatHistories = { ...chatHistories }; + if (newChatHistories[sectionId] && newChatHistories[sectionId][chatId]) { + newChatHistories[sectionId][chatId] = [ + ...newChatHistories[sectionId][chatId], + message, + ]; + setChatHistories(newChatHistories); + localStorage.setItem( + `chat/${sectionId}/${chatId}`, + JSON.stringify(newChatHistories[sectionId][chatId]) + ); + } + }; + + return ( + + {children} + + ); +} diff --git a/app/[docs_id]/chatServer.ts b/app/[docs_id]/chatServer.ts deleted file mode 100644 index 3a11a95..0000000 --- a/app/[docs_id]/chatServer.ts +++ /dev/null @@ -1,3 +0,0 @@ -"use server"; - -export async function hello() {} diff --git a/app/[docs_id]/markdown.tsx b/app/[docs_id]/markdown.tsx index ffdfd0a..002dd80 100644 --- a/app/[docs_id]/markdown.tsx +++ b/app/[docs_id]/markdown.tsx @@ -2,7 +2,6 @@ import Markdown, { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import SyntaxHighlighter from "react-syntax-highlighter"; import { PythonEmbeddedTerminal } from "../terminal/python/embedded"; -import { Heading } from "./section"; import { type AceLang, EditorComponent } from "../terminal/editor"; import { ExecFile, ExecLang } from "../terminal/exec"; import { useChangeTheme } from "./themeToggle"; @@ -10,6 +9,7 @@ import { tomorrow, atomOneDark, } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { ReactNode } from "react"; export function StyledMarkdown({ content }: { content: string }) { return ( @@ -50,6 +50,32 @@ const components: Components = { ), }; + + +export function Heading({ + level, + children, +}: { + level: number; + children: ReactNode; +}) { + switch (level) { + 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, @@ -104,6 +130,23 @@ function CodeComponent({ ); } + } else if (match[2] === "-repl") { + // repl付きの言語指定 + // 現状はPythonのみ対応 + switch (match[1]) { + case "python": + return ( +
+ +
+ ); + default: + console.warn(`Unsupported language for repl: ${match[1]}`); + break; + } } else if (match[3]) { // ファイル名指定がある場合、ファイルエディター let aceLang: AceLang | undefined = undefined; @@ -140,22 +183,6 @@ function CodeComponent({ /> ); - } else if (match[2] === "-repl") { - // repl付きの言語指定 - // 現状はPythonのみ対応 - switch (match[1]) { - case "python": - return ( -
- -
- ); - default: - console.warn(`Unsupported language for repl: ${match[1]}`); - break; - } } return ( - {splitMdContent.map((section, index) => { - const sectionId = `${docs_id}-${index}`; - return ( -
-
-
- ); - })} - + + + ); } diff --git a/app/[docs_id]/pageContent.tsx b/app/[docs_id]/pageContent.tsx new file mode 100644 index 0000000..9394900 --- /dev/null +++ b/app/[docs_id]/pageContent.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { MarkdownSection } from "./splitMarkdown"; +import { ChatForm } from "./chatForm"; +import { Heading, StyledMarkdown } from "./markdown"; +import { useChatHistoryContext } from "./chatHistory"; +import clsx from "clsx"; + +// MarkdownSectionに追加で、ユーザーが今そのセクションを読んでいるかどうか、などの動的な情報を持たせる +export type DynamicMarkdownSection = MarkdownSection & { + inView: boolean; + sectionId: string; +}; + +interface PageContentProps { + documentContent: string; + splitMdContent: MarkdownSection[]; + docs_id: string; +} +export function PageContent(props: PageContentProps) { + const [dynamicMdContent, setDynamicMdContent] = useState< + DynamicMarkdownSection[] + >( + // useEffectで更新するのとは別に、SSRのための初期値 + props.splitMdContent.map((section, i) => ({ + ...section, + inView: false, + sectionId: `${props.docs_id}-${i}`, + })) + ); + useEffect(() => { + // props.splitMdContentが変わったときにdynamicMdContentを更新 + setDynamicMdContent( + props.splitMdContent.map((section, i) => ({ + ...section, + inView: false, + sectionId: `${props.docs_id}-${i}`, + })) + ); + }, [props.splitMdContent, props.docs_id]); + + const sectionRefs = useRef>([]); + // sectionRefsの長さをsplitMdContentに合わせる + while (sectionRefs.current.length < props.splitMdContent.length) { + sectionRefs.current.push(null); + } + + useEffect(() => { + const handleScroll = () => { + setDynamicMdContent((prevDynamicMdContent) => { + const dynMdContent = prevDynamicMdContent.slice(); // Reactの変更検知のために新しい配列を作成 + for (let i = 0; i < sectionRefs.current.length; i++) { + if (sectionRefs.current.at(i) && dynMdContent.at(i)) { + const rect = sectionRefs.current.at(i)!.getBoundingClientRect(); + dynMdContent.at(i)!.inView = + rect.top < window.innerHeight && rect.bottom >= 0; + } + } + return dynMdContent; + }); + }; + window.addEventListener("scroll", handleScroll); + handleScroll(); + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); + + const [isFormVisible, setIsFormVisible] = useState(false); + + const { chatHistories } = useChatHistoryContext(); + + return ( +
+ {dynamicMdContent.map((section, index) => ( + <> +
{ + sectionRefs.current[index] = el; + }} + > + {/* ドキュメントのコンテンツ */} + {section.title} + +
+
+ {/* 右側に表示するチャット履歴欄 */} + {Object.entries(chatHistories[section.sectionId] ?? {}).map( + ([chatId, messages]) => ( +
+
+ {messages.map((msg, index) => ( +
+
+ +
+
+ ))} +
+
+ ) + )} +
+ + ))} + {isFormVisible ? ( + // sidebarの幅が80であることからleft-84 (sidebar.tsx参照) + // replがz-10を使用することからそれの上にするためz-20 +
+ setIsFormVisible(false)} + /> +
+ ) : ( + + )} +
+ ); +} diff --git a/app/[docs_id]/section.tsx b/app/[docs_id]/section.tsx deleted file mode 100644 index 4aada64..0000000 --- a/app/[docs_id]/section.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { - createContext, - ReactNode, - useCallback, - useContext, - useState, -} from "react"; -import { type MarkdownSection } from "./splitMarkdown"; -import { StyledMarkdown } from "./markdown"; -import { ChatForm } from "./chatForm"; -import { ReplCommand, ReplOutput } from "../terminal/repl"; -import { useFile } from "../terminal/file"; - -// セクション内に埋め込まれているターミナルとファイルエディターの内容をSection側から取得できるよう、 -// Contextに保存する -// TODO: C++では複数ファイルを実行する場合がありうるが、ここではfilenameを1つしか受け付けない想定になっている -interface ISectionCodeContext { - addReplOutput: (command: string, output: ReplOutput[]) => void; - addFile: (filename: string) => void; - setExecResult: (filename: string, output: ReplOutput[]) => void; -} -const SectionCodeContext = createContext(null); -export const useSectionCode = () => useContext(SectionCodeContext); - -interface SectionProps { - section: MarkdownSection; - sectionId: string; -} - -// 1つのセクションのタイトルと内容を表示する。内容はMarkdownとしてレンダリングする -export function Section({ section, sectionId }: SectionProps) { - const [replOutputs, setReplOutputs] = useState([]); - const [execResults, setExecResults] = useState>( - {} - ); - const [filenames, setFilenames] = useState([]); - const { files } = useFile(); - const fileContents: { name: string; content: string }[] = filenames.map( - (name) => ({ name, content: files[name] || "" }) - ); - const addReplOutput = useCallback( - (command: string, output: ReplOutput[]) => - setReplOutputs((outs) => [...outs, { command, output }]), - [] - ); - const addFile = useCallback( - (filename: string) => - setFilenames((filenames) => - filenames.includes(filename) ? filenames : [...filenames, filename] - ), - [] - ); - const setExecResult = useCallback( - (filename: string, output: ReplOutput[]) => - setExecResults((results) => { - results[filename] = output; - return results; - }), - [] - ); - - // replOutputs: section内にあるターミナルにユーザーが入力したコマンドとその実行結果 - // fileContents: section内にあるファイルエディターの内容 - // execResults: section内にあるファイルの実行結果 - // console.log(section.title, replOutputs, fileContents, execResults); - - return ( - -
- {section.title} - - -
-
- ); -} - -export function Heading({ - level, - children, -}: { - level: number; - children: ReactNode; -}) { - switch (level) { - 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/[docs_id]/splitMarkdown.ts b/app/[docs_id]/splitMarkdown.ts index 55fc0d0..0af31c8 100644 --- a/app/[docs_id]/splitMarkdown.ts +++ b/app/[docs_id]/splitMarkdown.ts @@ -6,6 +6,7 @@ export interface MarkdownSection { level: number; title: string; content: string; + rawContent: string; // 見出しも含めたもとのmarkdownの内容 } /** * Markdownコンテンツを見出しごとに分割し、 @@ -36,6 +37,10 @@ export function splitMarkdown(content: string): MarkdownSection[] { .join("\n") .trim(), level: headingNodes.at(i)!.depth, + rawContent: splitContent + .slice(startLine - 1, endLine ? endLine - 1 : undefined) + .join("\n") + .trim(), }); } return sections; diff --git a/app/[docs_id]/themeToggle.tsx b/app/[docs_id]/themeToggle.tsx index b9ee650..1b4fd4a 100644 --- a/app/[docs_id]/themeToggle.tsx +++ b/app/[docs_id]/themeToggle.tsx @@ -30,7 +30,7 @@ export function ThemeToggle() { }, []); return ( -