From 4e6d8812463c6eaa359dc3b12b736100b4ea60ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:20:36 +0000 Subject: [PATCH 1/4] Initial plan From 87bd327b00aebbedc0820c70fe65dd4edcaabfd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:50:50 +0000 Subject: [PATCH 2/4] feat: implement streaming AI responses via Route Handler - Add app/lib/ai.ts with generateContentStream() for Gemini/OpenRouter - Add createChatOnly() and addMessagesAndDiffs() to chatHistory.ts - Create app/api/chat/route.ts Route Handler with NDJSON streaming - Create app/(docs)/streamingChatContext.tsx for real-time streaming state - Update layout.tsx to include StreamingChatProvider - Update chatForm.tsx to use fetch() + stream reading instead of server action - Update chatArea.tsx to display streaming content in real-time Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/(docs)/@chat/chat/[chatId]/chatArea.tsx | 114 ++++-- app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx | 119 +++++-- app/(docs)/layout.tsx | 19 +- app/(docs)/streamingChatContext.tsx | 71 ++++ app/api/chat/route.ts | 324 ++++++++++++++++++ app/lib/ai.ts | 104 ++++++ app/lib/chatHistory.ts | 68 ++++ package-lock.json | 158 ++------- 8 files changed, 773 insertions(+), 204 deletions(-) create mode 100644 app/(docs)/streamingChatContext.tsx create mode 100644 app/api/chat/route.ts create mode 100644 app/lib/ai.ts diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index 80c28aba..bdcbe3dc 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -1,6 +1,7 @@ "use client"; import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState"; +import { useStreamingChat } from "@/(docs)/streamingChatContext"; import { deleteChatAction } from "@/actions/deleteChat"; import { ChatWithMessages } from "@/lib/chatHistory"; import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs"; @@ -9,7 +10,7 @@ import { StyledMarkdown } from "@/markdown/markdown"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ReactNode } from "react"; +import { ReactNode, useEffect, useRef } from "react"; export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) { return ( @@ -75,6 +76,30 @@ export function ChatAreaContent(props: Props) { ); const router = useRouter(); + const streaming = useStreamingChat(); + const isStreamingThis = streaming.streamingChatId === chatId; + const hasRefreshedRef = useRef(false); + + useEffect(() => { + if (!isStreamingThis || streaming.isStreaming) { + hasRefreshedRef.current = false; + return; + } + // ストリーミングが終了した + if (chatData.messages.length > 0) { + // DBにデータが揃った → ストリーミング状態を解除 + streaming.clearStreaming(); + hasRefreshedRef.current = false; + } else if (!hasRefreshedRef.current) { + // DBがまだ更新されていない → 再読み込みして最新データを取得 + hasRefreshedRef.current = true; + router.refresh(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isStreamingThis, streaming.isStreaming, chatData.messages.length, streaming.clearStreaming, router]); + + // ストリーミング中または完了直後(DBリフレッシュ前)はストリーミングコンテンツを表示 + const showStreaming = isStreamingThis; return ( <> @@ -161,46 +186,65 @@ export function ChatAreaContent(props: Props) {
- {messagesAndDiffs.map((msg, index) => - msg.type === "message" ? ( - msg.role === "user" ? ( -
-
+ {showStreaming ? ( + <> +
+
+ +
+
+
+ + {streaming.isStreaming && ( + + )} +
+ + ) : ( + messagesAndDiffs.map((msg, index) => + msg.type === "message" ? ( + msg.role === "user" ? ( +
+
+ +
+
+ ) : msg.role === "ai" ? ( +
-
- ) : msg.role === "ai" ? ( -
- -
+ ) : ( +
+ {msg.content} +
+ ) ) : ( -
- {msg.content} -
- ) - ) : ( -
- {/* pb-0だとmargin collapsingが起きて変な隙間が空く */} - - - - - - -
+ {/* pb-0だとmargin collapsingが起きて変な隙間が空く */} + + + + + + +
+ ) ) )} diff --git a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx index 65dfd501..3604898a 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx @@ -9,9 +9,9 @@ import { useState, FormEvent, useEffect } from "react"; // import { getLanguageName } from "../pagesList"; import { DynamicMarkdownSection } from "./pageContent"; import { useEmbedContext } from "@/terminal/embedContext"; -import { askAI } from "@/actions/chatActions"; import { PagePath } from "@/lib/docs"; import { useRouter } from "next/navigation"; +import { useStreamingChat } from "@/(docs)/streamingChatContext"; interface ChatFormProps { path: PagePath; @@ -30,6 +30,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const { files, replOutputs, execResults } = useEmbedContext(); const router = useRouter(); + const streamingChat = useStreamingChat(); // const documentContentInView = sectionContent // .filter((s) => s.inView) @@ -64,39 +65,101 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setIsLoading(true); - setErrorMessage(null); // Clear previous error message + setErrorMessage(null); const userQuestion = inputValue; - // if (!userQuestion && exampleData) { - // // 質問が空欄なら、質問例を使用 - // userQuestion = - // exampleData[Math.floor(exampleChoice * exampleData.length)]; - // setInputValue(userQuestion); - // } - - const result = await askAI({ - path, - userQuestion, - sectionContent, - replOutputs, - files, - execResults, - }); - if (result.error !== null) { - setErrorMessage(result.error); - console.log(result.error); - } else { - document.getElementById(result.chat.sectionId)?.scrollIntoView({ - behavior: "smooth", + let response: Response; + try { + response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + userQuestion, + sectionContent, + replOutputs, + files, + execResults, + }), }); - router.push(`/chat/${result.chat.chatId}`, { scroll: false }); - router.refresh(); - setInputValue(""); - close(); + } catch { + setErrorMessage("AIへの接続に失敗しました"); + setIsLoading(false); + return; + } + + if (!response.ok) { + setErrorMessage(`エラーが発生しました (${response.status})`); + setIsLoading(false); + return; } - setIsLoading(false); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let navigated = false; + + // ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続) + const readStream = async () => { + while (true) { + let result: ReadableStreamReadResult; + try { + result = await reader.read(); + } catch (err) { + console.error("Stream connection interrupted:", err); + break; + } + const { done, value } = result; + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as + | { type: "chat"; chatId: string; sectionId: string } + | { type: "chunk"; text: string } + | { type: "done" } + | { type: "error"; message: string }; + + if (event.type === "chat") { + streamingChat.startStreaming(event.chatId, userQuestion); + document.getElementById(event.sectionId)?.scrollIntoView({ + behavior: "smooth", + }); + router.push(`/chat/${event.chatId}`, { scroll: false }); + navigated = true; + setIsLoading(false); + setInputValue(""); + close(); + } else if (event.type === "chunk") { + streamingChat.appendChunk(event.text); + } else if (event.type === "done") { + streamingChat.finishStreaming(); + } else if (event.type === "error") { + if (!navigated) { + setErrorMessage(event.message); + setIsLoading(false); + } + streamingChat.finishStreaming(); + } + } catch { + // ignore JSON parse errors + } + } + } + }; + + // ストリーム読み込みはバックグラウンドで継続(awaitしない) + readStream().catch((err) => { + console.error("Stream reading failed:", err); + // ナビゲーション後のエラーはストリーミングを終了してローディングを止める + streamingChat.finishStreaming(); + }); }; return ( diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index bee7fa3e..ae6226d2 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react"; import { ChatAreaStateProvider } from "./chatAreaState"; +import { StreamingChatProvider } from "./streamingChatContext"; // app/(workspace)/layout.tsx export default function WorkspaceLayout({ @@ -12,15 +13,17 @@ export default function WorkspaceLayout({ chat: ReactNode; }) { return ( - -
- {docs} + + +
+ {docs} - {chat} + {chat} - {/* children(page.tsx)は今回は使わないか、背景として利用 */} - {children} -
-
+ {/* children(page.tsx)は今回は使わないか、背景として利用 */} + {children} +
+
+ ); } diff --git a/app/(docs)/streamingChatContext.tsx b/app/(docs)/streamingChatContext.tsx new file mode 100644 index 00000000..9eba94e3 --- /dev/null +++ b/app/(docs)/streamingChatContext.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { createContext, ReactNode, useContext, useState } from "react"; + +export interface StreamingChatState { + streamingChatId: string | null; + userQuestion: string; + streamingContent: string; + isStreaming: boolean; +} + +interface StreamingChatActions { + startStreaming: (chatId: string, userQuestion: string) => void; + appendChunk: (chunk: string) => void; + finishStreaming: () => void; + clearStreaming: () => void; +} + +const StreamingChatContext = createContext< + StreamingChatState & StreamingChatActions +>(null!); + +export function useStreamingChat() { + return useContext(StreamingChatContext); +} + +export function StreamingChatProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + streamingChatId: null, + userQuestion: "", + streamingContent: "", + isStreaming: false, + }); + + const startStreaming = (chatId: string, userQuestion: string) => { + setState({ + streamingChatId: chatId, + userQuestion, + streamingContent: "", + isStreaming: true, + }); + }; + + const appendChunk = (chunk: string) => { + setState((prev) => ({ + ...prev, + streamingContent: prev.streamingContent + chunk, + })); + }; + + const finishStreaming = () => { + setState((prev) => ({ ...prev, isStreaming: false })); + }; + + const clearStreaming = () => { + setState({ + streamingChatId: null, + userQuestion: "", + streamingContent: "", + isStreaming: false, + }); + }; + + return ( + + {children} + + ); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..219ece5e --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,324 @@ +import { NextRequest } from "next/server"; +import { generateContentStream } from "@/lib/ai"; +import { + addMessagesAndDiffs, + createChatOnly, + CreateChatDiff, + initContext, +} from "@/lib/chatHistory"; +import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; +import { DynamicMarkdownSection } from "@/(docs)/@docs/[lang]/[pageId]/pageContent"; +import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; + +type ChatParams = { + path: PagePath; + userQuestion: string; + sectionContent: DynamicMarkdownSection[]; + replOutputs: Record; + files: Record; + execResults: Record; +}; + +type StreamEvent = + | { type: "chat"; chatId: string; sectionId: string } + | { type: "chunk"; text: string } + | { 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 params = (await request.json()) as ChatParams; + const { path, userQuestion, sectionContent, replOutputs, files, execResults } = + params; + + const pagesList = await getPagesList(); + const langName = pagesList.find((lang) => lang.id === path.lang)?.name; + + const prompt: string[] = []; + + prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); + prompt.push( + `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` + ); + prompt.push(``); + const sectionTitlesInView = sectionContent + .filter((s) => s.inView) + .map((s) => s.title) + .join(", "); + prompt.push( + `ユーザーはドキュメント内の ${sectionTitlesInView} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。` + ); + prompt.push( + `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` + ); + 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.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("- ドキュメントの一部を改訂したい場合はその差分を"); + prompt.push("<<<<<<< SEARCH"); + prompt.push("修正したい元の文章の塊(一字一句違わずに)"); + prompt.push("======="); + prompt.push("修正後の新しい文章の塊"); + prompt.push(">>>>>>> REPLACE"); + prompt.push("の形式で出力してください。"); + prompt.push( + " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" + ); + prompt.push( + " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" + ); + prompt.push( + " - セクションid、セクション見出し、およびコードブロックの内側を編集することはできません。それ以外の文章のみを編集してください。" + ); + prompt.push( + " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(修正後のドキュメントを参照してください、など)" + ); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + function send(event: StreamEvent) { + controller.enqueue(encoder.encode(JSON.stringify(event) + "\n")); + } + + try { + let fullText = ""; + let headerParsed = false; + let chatId: string | undefined; + let contentAfterHeader = ""; + + for await (const chunk of generateContentStream( + userQuestion, + prompt.join("\n") + )) { + fullText += chunk; + + if (!headerParsed) { + // Wait until we have at least 2 lines (sectionId + title + start of body) + const headerMatch = fullText.match(/^([^\n]*?)\n+([^\n]*?)\n+/); + if (headerMatch) { + headerParsed = true; + let targetSectionId = headerMatch[1].trim() as SectionId; + const title = headerMatch[2].trim(); + + if ( + !targetSectionId || + !sectionContent.some((s) => s.id === targetSectionId) + ) { + targetSectionId = introSectionId(path); + } + + if (!title) { + send({ + type: "error", + message: "AIからの応答にタイトルが含まれていませんでした", + }); + controller.close(); + return; + } + + // Create chat record in DB immediately + const newChat = await createChatOnly( + path, + targetSectionId, + title, + context + ); + chatId = newChat.chatId; + + // Notify client with chatId so navigation can happen + send({ + type: "chat", + chatId, + sectionId: targetSectionId, + }); + + // Send any content that came after the header in this chunk + contentAfterHeader = fullText.slice(headerMatch[0].length); + if (contentAfterHeader) { + send({ type: "chunk", text: contentAfterHeader }); + } + } + } else { + // Header already parsed - stream the chunk directly + contentAfterHeader += chunk; + send({ type: "chunk", text: chunk }); + } + } + + // AI response finished + if (!chatId) { + // Header was never parsed (e.g. very short response without 2 newlines) + send({ + type: "error", + message: "AIからの応答の形式が正しくありませんでした", + }); + controller.close(); + return; + } + + // Parse diffs from the full body content + 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 = sectionContent.find((s) => + s.replacedContent.includes(search) + ); + diffRaw.push({ + search, + replace, + sectionId: targetSection?.id ?? ("" as SectionId), + targetMD5: targetSection?.md5 ?? "", + }); + } + const cleanMessage = contentAfterHeader.replace(diffRegex, "").trim(); + + // Save messages and diffs to DB + await addMessagesAndDiffs( + chatId, + path, + [ + { role: "user", content: userQuestion }, + { role: "ai", content: cleanMessage }, + ], + diffRaw, + context + ); + + send({ type: "done" }); + controller.close(); + } catch (error: unknown) { + console.error("Error in AI streaming:", error); + try { + controller.enqueue( + encoder.encode( + JSON.stringify({ type: "error", message: String(error) }) + "\n" + ) + ); + } catch { + // controller might already be closed + } + 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/lib/ai.ts b/app/lib/ai.ts new file mode 100644 index 00000000..9bf70a75 --- /dev/null +++ b/app/lib/ai.ts @@ -0,0 +1,104 @@ +import { GoogleGenAI } from "@google/genai"; + +/** + * AI APIからコンテンツをストリーミング生成する非同期ジェネレーター。 + * OpenRouter (環境変数が設定されている場合) または Google Gemini を使用する。 + */ +export async function* generateContentStream( + prompt: string, + systemInstruction?: string +): AsyncGenerator { + const openRouterApiKey = process.env.OPENROUTER_API_KEY; + const openRouterModel = process.env.OPENROUTER_MODEL; + + if (openRouterApiKey && openRouterModel) { + const models = openRouterModel + .split(";") + .map((m) => m.trim()) + .filter(Boolean); + + const messages: { role: string; content: string }[] = []; + if (systemInstruction) { + messages.push({ role: "system", content: systemInstruction }); + } + messages.push({ role: "user", content: prompt }); + + const response = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openRouterApiKey}`, + }, + body: JSON.stringify({ models, messages, stream: true }), + } + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error( + `OpenRouter APIエラー: ${response.status} ${response.statusText} - ${body}` + ); + } + + 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.startsWith("data: ")) continue; + const data = line.slice(6); + if (data === "[DONE]") return; + try { + const parsed = JSON.parse(data) as { + choices?: { delta?: { content?: string | null } }[]; + }; + const content = parsed.choices?.[0]?.delta?.content; + if (content) yield content; + } catch { + // ignore parse errors for malformed SSE lines + } + } + } + return; + } + + const params = { + model: "gemini-2.5-flash", + contents: prompt, + config: { + systemInstruction, + }, + }; + + const ai = new GoogleGenAI({ apiKey: process.env.API_KEY! }); + + try { + const stream = await ai.models.generateContentStream(params); + for await (const chunk of stream) { + if (chunk.text) yield chunk.text; + } + } catch (e: unknown) { + if (String(e).includes("User location is not supported")) { + const aiWithProxy = new GoogleGenAI({ + apiKey: process.env.API_KEY!, + httpOptions: { + baseUrl: "https://gemini-proxy.utcode.net", + }, + }); + const stream = await aiWithProxy.models.generateContentStream(params); + for await (const chunk of stream) { + if (chunk.text) yield chunk.text; + } + } else { + throw e; + } + } +} diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index cc28ce4c..8fac08af 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -141,6 +141,74 @@ export async function addChat( export type ChatWithMessages = Awaited>; +/** + * チャットレコードのみを作成する(メッセージ・差分は含まない)。 + * ストリーミング時に、AIの回答が完全に届く前にチャットIDを確定させるために使用する。 + */ +export async function createChatOnly( + path: PagePath, + sectionId: SectionId, + title: string, + context: Context +) { + const { drizzle, userId } = context; + if (!userId) { + throw new Error("Not authenticated"); + } + const [newChat] = await drizzle + .insert(chat) + .values({ + userId, + sectionId, + title, + }) + .returning(); + + return { + ...newChat, + section: { + sectionId, + pagePath: `${path.lang}/${path.page}`, + }, + }; +} + +/** + * 既存のチャットにメッセージと差分を追加し、キャッシュを再検証する。 + * ストリーミング完了後に使用する。 + */ +export async function addMessagesAndDiffs( + chatId: string, + path: PagePath, + messages: CreateChatMessage[], + diffRaw: CreateChatDiff[], + context: Context +) { + const { drizzle, userId } = context; + if (!userId) { + throw new Error("Not authenticated"); + } + + await drizzle.insert(message).values( + messages.map((msg) => ({ + chatId, + role: msg.role, + content: msg.content, + })) + ); + + if (diffRaw.length > 0) { + await drizzle.insert(diff).values( + diffRaw.map((d) => ({ + chatId, + ...d, + })) + ); + } + + await revalidateChat(chatId, userId, path); +} + export async function deleteChat(chatId: string, context: Context) { const { drizzle, userId } = context; if (!userId) { diff --git a/package-lock.json b/package-lock.json index c1c4b215..e06b8298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1961,7 +1961,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1978,7 +1977,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1995,7 +1993,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2012,7 +2009,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2029,7 +2025,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2046,7 +2041,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2063,7 +2057,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2080,7 +2073,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2097,7 +2089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2114,7 +2105,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2131,7 +2121,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2148,7 +2137,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2165,7 +2153,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2182,7 +2169,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2199,7 +2185,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2216,7 +2201,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2233,7 +2217,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2250,7 +2233,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2267,7 +2249,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2284,7 +2265,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2301,7 +2281,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2318,7 +2297,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4857,8 +4835,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -4871,8 +4848,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -4885,8 +4861,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -4899,8 +4874,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -4913,8 +4887,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -4927,8 +4900,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -4941,8 +4913,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -4955,8 +4926,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -4969,8 +4939,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -4983,8 +4952,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -4997,8 +4965,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -5011,8 +4978,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -5025,8 +4991,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -5039,8 +5004,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -5053,8 +5017,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -5067,8 +5030,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -5081,8 +5043,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -5095,8 +5056,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -5109,8 +5069,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -5123,8 +5082,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -5137,8 +5095,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -5151,8 +5108,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -5165,8 +5121,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -5179,8 +5134,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -5193,8 +5147,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -13983,7 +13936,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14004,7 +13956,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14025,7 +13976,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14046,7 +13996,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14067,7 +14016,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14088,7 +14036,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14109,7 +14056,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14130,7 +14076,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14151,7 +14096,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14172,7 +14116,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14193,7 +14136,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -19444,7 +19386,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19461,7 +19402,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19478,7 +19418,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19495,7 +19434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19512,7 +19450,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19529,7 +19466,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19546,7 +19482,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19563,7 +19498,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19580,7 +19514,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19597,7 +19530,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19614,7 +19546,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19631,7 +19562,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19648,7 +19578,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19665,7 +19594,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19682,7 +19610,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19699,7 +19626,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19716,7 +19642,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19733,7 +19658,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19750,7 +19674,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19767,7 +19690,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19784,7 +19706,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19801,7 +19722,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19818,7 +19738,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19835,7 +19754,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -19852,7 +19770,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -20411,7 +20328,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -20428,7 +20344,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -20445,7 +20360,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -20462,7 +20376,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -20479,7 +20392,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -20496,7 +20408,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -20513,7 +20424,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20530,7 +20440,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20547,7 +20456,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20564,7 +20472,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20581,7 +20488,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20598,7 +20504,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20615,7 +20520,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20632,7 +20536,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20649,7 +20552,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20666,7 +20568,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20683,7 +20584,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -20700,7 +20600,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20717,7 +20616,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20734,7 +20632,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20751,7 +20648,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20768,7 +20664,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -20785,7 +20680,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -20802,7 +20696,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -20819,7 +20712,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } From 7c16a3e6ab8dacf219429f340b479e862a48b0bb Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:18:25 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=83=9F=E3=83=B3=E3=82=B0=E3=81=A7=E8=A1=A8=E7=A4=BA=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=81=AE=E3=81=AFai=E3=83=A1=E3=83=83=E3=82=BB?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=AE=E3=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(docs)/@chat/chat/[chatId]/chatArea.tsx | 126 ++++----- app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx | 106 ++++--- app/(docs)/streamingChatContext.tsx | 83 +++--- app/actions/chatActions.ts | 263 ------------------ app/api/chat/route.ts | 15 +- app/lib/chatHistory.ts | 32 --- 6 files changed, 142 insertions(+), 483 deletions(-) delete mode 100644 app/actions/chatActions.ts diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index bdcbe3dc..babd2581 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -1,7 +1,7 @@ "use client"; import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState"; -import { useStreamingChat } from "@/(docs)/streamingChatContext"; +import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; import { deleteChatAction } from "@/actions/deleteChat"; import { ChatWithMessages } from "@/lib/chatHistory"; import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs"; @@ -12,7 +12,10 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { ReactNode, useEffect, useRef } from "react"; -export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) { +export function ChatAreaContainer(props: { + chatId: string; + children: ReactNode; +}) { return (
- {showStreaming ? ( - <> -
-
- -
-
-
- - {streaming.isStreaming && ( - - )} -
- - ) : ( - messagesAndDiffs.map((msg, index) => - msg.type === "message" ? ( - msg.role === "user" ? ( -
-
- -
-
- ) : msg.role === "ai" ? ( -
+ {messagesAndDiffs.map((msg, index) => + msg.type === "message" ? ( + msg.role === "user" ? ( +
+
- ) : ( -
- {msg.content} -
- ) +
+ ) : msg.role === "ai" ? ( +
+ +
) : ( -
+ {msg.content} +
+ ) + ) : ( +
+ {/* pb-0だとmargin collapsingが起きて変な隙間が空く */} + - {/* pb-0だとmargin collapsingが起きて変な隙間が空く */} - - - - - - -
- ) + + + + + +
) )} + {isStreamingThis && ( +
+ + +
+ )} ); } diff --git a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx index 3604898a..349f07bb 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx @@ -11,7 +11,8 @@ import { DynamicMarkdownSection } from "./pageContent"; import { useEmbedContext } from "@/terminal/embedContext"; import { PagePath } from "@/lib/docs"; import { useRouter } from "next/navigation"; -import { useStreamingChat } from "@/(docs)/streamingChatContext"; +import { ChatStreamEvent } from "@/api/chat/route"; +import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; interface ChatFormProps { path: PagePath; @@ -30,7 +31,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { const { files, replOutputs, execResults } = useEmbedContext(); const router = useRouter(); - const streamingChat = useStreamingChat(); + const streamingChatContext = useStreamingChatContext(); // const documentContentInView = sectionContent // .filter((s) => s.inView) @@ -101,65 +102,60 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) { let navigated = false; // ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続) - const readStream = async () => { - while (true) { - let result: ReadableStreamReadResult; - try { - result = await reader.read(); - } catch (err) { - console.error("Stream connection interrupted:", err); - break; - } - const { done, value } = result; - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as - | { type: "chat"; chatId: string; sectionId: string } - | { type: "chunk"; text: string } - | { type: "done" } - | { type: "error"; message: string }; - - if (event.type === "chat") { - streamingChat.startStreaming(event.chatId, userQuestion); - document.getElementById(event.sectionId)?.scrollIntoView({ - behavior: "smooth", - }); - router.push(`/chat/${event.chatId}`, { scroll: false }); - navigated = true; - setIsLoading(false); - setInputValue(""); - close(); - } else if (event.type === "chunk") { - streamingChat.appendChunk(event.text); - } else if (event.type === "done") { - streamingChat.finishStreaming(); - } else if (event.type === "error") { - if (!navigated) { - setErrorMessage(event.message); + void (async () => { + try { + while (true) { + const result = await reader.read(); + const { done, value } = result; + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as ChatStreamEvent; + + if (event.type === "chat") { + streamingChatContext.startStreaming(event.chatId); + document.getElementById(event.sectionId)?.scrollIntoView({ + behavior: "smooth", + }); + router.push(`/chat/${event.chatId}`, { scroll: false }); + router.refresh(); + navigated = true; setIsLoading(false); + setInputValue(""); + close(); + } else if (event.type === "chunk") { + streamingChatContext.appendChunk(event.text); + } else if (event.type === "done") { + streamingChatContext.finishStreaming(); + router.refresh(); + } else if (event.type === "error") { + if (!navigated) { + setErrorMessage(event.message); + setIsLoading(false); + } + streamingChatContext.finishStreaming(); } - streamingChat.finishStreaming(); + } catch { + // ignore JSON parse errors } - } catch { - // ignore JSON parse errors } } + } catch (err) { + console.error("Stream reading failed:", err); + // ナビゲーション後のエラーはストリーミングを終了してローディングを止める + if (!navigated) { + setErrorMessage(String(err)); + setIsLoading(false); + } + streamingChatContext.finishStreaming(); } - }; - - // ストリーム読み込みはバックグラウンドで継続(awaitしない) - readStream().catch((err) => { - console.error("Stream reading failed:", err); - // ナビゲーション後のエラーはストリーミングを終了してローディングを止める - streamingChat.finishStreaming(); - }); + })(); }; return ( diff --git a/app/(docs)/streamingChatContext.tsx b/app/(docs)/streamingChatContext.tsx index 9eba94e3..0af506ec 100644 --- a/app/(docs)/streamingChatContext.tsx +++ b/app/(docs)/streamingChatContext.tsx @@ -1,69 +1,54 @@ "use client"; -import { createContext, ReactNode, useContext, useState } from "react"; - -export interface StreamingChatState { - streamingChatId: string | null; - userQuestion: string; - streamingContent: string; - isStreaming: boolean; -} - -interface StreamingChatActions { - startStreaming: (chatId: string, userQuestion: string) => void; +import { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from "react"; + +interface StreamingChatContextData { + chatId: string | null; + content: string; + startStreaming: (chatId: string) => void; appendChunk: (chunk: string) => void; finishStreaming: () => void; - clearStreaming: () => void; } -const StreamingChatContext = createContext< - StreamingChatState & StreamingChatActions ->(null!); +const StreamingChatContext = createContext(null!); -export function useStreamingChat() { +export function useStreamingChatContext() { return useContext(StreamingChatContext); } export function StreamingChatProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - streamingChatId: null, - userQuestion: "", - streamingContent: "", - isStreaming: false, - }); - - const startStreaming = (chatId: string, userQuestion: string) => { - setState({ - streamingChatId: chatId, - userQuestion, - streamingContent: "", - isStreaming: true, - }); - }; + const [chatId, setChatId] = useState(null); + const [content, setContent] = useState(""); - const appendChunk = (chunk: string) => { - setState((prev) => ({ - ...prev, - streamingContent: prev.streamingContent + chunk, - })); - }; + const startStreaming = useCallback((chatId: string) => { + setContent(""); + setChatId(chatId); + }, []); - const finishStreaming = () => { - setState((prev) => ({ ...prev, isStreaming: false })); - }; + const appendChunk = useCallback((chunk: string) => { + setContent((prev) => prev + chunk); + }, []); - const clearStreaming = () => { - setState({ - streamingChatId: null, - userQuestion: "", - streamingContent: "", - isStreaming: false, - }); - }; + const finishStreaming = useCallback(() => { + setChatId(null); + setContent(""); + }, []); return ( {children} diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts deleted file mode 100644 index de3183dc..00000000 --- a/app/actions/chatActions.ts +++ /dev/null @@ -1,263 +0,0 @@ -"use server"; - -// import { z } from "zod"; -import { generateContent } from "./gemini"; -import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; -import { - addChat, - ChatWithMessages, - CreateChatDiff, - initContext, -} from "@/lib/chatHistory"; -import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; -import { DynamicMarkdownSection } from "@/(docs)/@docs/[lang]/[pageId]/pageContent"; - -type ChatResult = - | { - error: string; - } - | { - error: null; - // サーバー側でデータベースに新しく追加されたチャットデータ - chat: ChatWithMessages; - }; - -type ChatParams = { - path: PagePath; - userQuestion: string; - sectionContent: DynamicMarkdownSection[]; - replOutputs: Record; - files: Record; - execResults: Record; -}; - -export async function askAI(params: ChatParams): Promise { - // const parseResult = ChatSchema.safeParse(params); - - // if (!parseResult.success) { - // return { - // response: "", - // error: parseResult.error.issues.map((e) => e.message).join(", "), - // }; - // } - - const { - path, - userQuestion, - sectionContent, - replOutputs, - files, - execResults, - } = params; - - const pagesList = await getPagesList(); - const langName = pagesList.find((lang) => lang.id === path.lang)?.name; - - const prompt: string[] = []; - - prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); - prompt.push( - `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` - ); - prompt.push(``); - const sectionTitlesInView = sectionContent - .filter((s) => s.inView) - .map((s) => s.title) - .join(", "); - prompt.push( - `ユーザーはドキュメント内の ${sectionTitlesInView} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。` - ); - prompt.push( - `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` - ); - 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.replacedContent.trim()); - prompt.push(``); - } - prompt.push(``); - // TODO: 各セクションのドキュメントの直下にそのセクション内のターミナルの情報を加えるべきなのでは? - 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("- ドキュメントの一部を改訂したい場合はその差分を"); - prompt.push("<<<<<<< SEARCH"); - prompt.push("修正したい元の文章の塊(一字一句違わずに)"); - prompt.push("======="); - prompt.push("修正後の新しい文章の塊"); - prompt.push(">>>>>>> REPLACE"); - prompt.push("の形式で出力してください。"); - prompt.push( - " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" - ); - prompt.push( - " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" - ); - prompt.push( - " - セクションid、セクション見出し、およびコードブロックの内側を編集することはできません。それ以外の文章のみを編集してください。" - ); - prompt.push( - " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(修正後のドキュメントを参照してください、など)" - ); - - console.log(prompt); - - try { - const result = await generateContent(userQuestion, prompt.join("\n")); - const text = result.text; - if (!text) { - throw new Error("AIからの応答が空でした"); - } - console.log(JSON.stringify(text)); - const textMatch = text.match(/^([^\n]*?)\n+([^\n]*?)\n+([\s\S]*)$/); - let targetSectionId = textMatch?.at(1)?.trim() as SectionId | undefined; - if ( - !targetSectionId || - !sectionContent.some((s) => s.id === targetSectionId) - ) { - targetSectionId = introSectionId(path); - } - const title = textMatch?.at(2)?.trim(); - if (!title) { - throw new Error("AIからの応答にタイトルが含まれていませんでした"); - } - let responseMessage = textMatch?.at(3)?.trim(); - if (!responseMessage) { - 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 responseMessage.matchAll(diffRegex) ?? []) { - const search = m[1]; - const replace = m[2]; - const targetSection = sectionContent.find((s) => - s.replacedContent.includes(search) - ); - diffRaw.push({ - search, - replace, - sectionId: targetSection?.id ?? ("" as SectionId), - targetMD5: targetSection?.md5 ?? "", - }); - } - responseMessage = responseMessage.replace(diffRegex, "").trim(); - const newChat = await addChat( - path, - targetSectionId, - title, - [ - { role: "user", content: userQuestion }, - { role: "ai", content: responseMessage }, - ], - diffRaw, - await initContext() - ); - return { - error: null, - chat: newChat, - }; - } catch (error: unknown) { - console.error("Error calling Generative AI:", error); - return { - error: String(error), - }; - } -} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 219ece5e..e37708a1 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,8 +1,8 @@ import { NextRequest } from "next/server"; import { generateContentStream } from "@/lib/ai"; import { + addChat, addMessagesAndDiffs, - createChatOnly, CreateChatDiff, initContext, } from "@/lib/chatHistory"; @@ -19,7 +19,7 @@ type ChatParams = { execResults: Record; }; -type StreamEvent = +export type ChatStreamEvent = | { type: "chat"; chatId: string; sectionId: string } | { type: "chunk"; text: string } | { type: "done" } @@ -181,11 +181,13 @@ export async function POST(request: NextRequest) { " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(修正後のドキュメントを参照してください、など)" ); + console.log(prompt); + const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { - function send(event: StreamEvent) { + function send(event: ChatStreamEvent) { controller.enqueue(encoder.encode(JSON.stringify(event) + "\n")); } @@ -199,6 +201,8 @@ export async function POST(request: NextRequest) { userQuestion, prompt.join("\n") )) { + console.log("Received chunk:", [chunk]); + fullText += chunk; if (!headerParsed) { @@ -226,10 +230,12 @@ export async function POST(request: NextRequest) { } // Create chat record in DB immediately - const newChat = await createChatOnly( + const newChat = await addChat( path, targetSectionId, title, + [{ role: "user", content: userQuestion }], + [], context ); chatId = newChat.chatId; @@ -289,7 +295,6 @@ export async function POST(request: NextRequest) { chatId, path, [ - { role: "user", content: userQuestion }, { role: "ai", content: cleanMessage }, ], diffRaw, diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index 8fac08af..c2182006 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -141,38 +141,6 @@ export async function addChat( export type ChatWithMessages = Awaited>; -/** - * チャットレコードのみを作成する(メッセージ・差分は含まない)。 - * ストリーミング時に、AIの回答が完全に届く前にチャットIDを確定させるために使用する。 - */ -export async function createChatOnly( - path: PagePath, - sectionId: SectionId, - title: string, - context: Context -) { - const { drizzle, userId } = context; - if (!userId) { - throw new Error("Not authenticated"); - } - const [newChat] = await drizzle - .insert(chat) - .values({ - userId, - sectionId, - title, - }) - .returning(); - - return { - ...newChat, - section: { - sectionId, - pagePath: `${path.lang}/${path.page}`, - }, - }; -} - /** * 既存のチャットにメッセージと差分を追加し、キャッシュを再検証する。 * ストリーミング完了後に使用する。 From 71e6986741771aa4b51bfbc0b42f2327039cc3e8 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:36:12 +0900 Subject: [PATCH 4/4] fix lint --- app/(docs)/@chat/chat/[chatId]/chatArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index babd2581..7b8c7e7f 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -10,7 +10,7 @@ import { StyledMarkdown } from "@/markdown/markdown"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ReactNode, useEffect, useRef } from "react"; +import { ReactNode } from "react"; export function ChatAreaContainer(props: { chatId: string;