Skip to content

Commit 87bd327

Browse files
Copilotna-trium-144
andcommitted
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>
1 parent 4e6d881 commit 87bd327

File tree

8 files changed

+773
-204
lines changed

8 files changed

+773
-204
lines changed

app/(docs)/@chat/chat/[chatId]/chatArea.tsx

Lines changed: 79 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
4+
import { useStreamingChat } from "@/(docs)/streamingChatContext";
45
import { deleteChatAction } from "@/actions/deleteChat";
56
import { ChatWithMessages } from "@/lib/chatHistory";
67
import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
@@ -9,7 +10,7 @@ import { StyledMarkdown } from "@/markdown/markdown";
910
import clsx from "clsx";
1011
import Link from "next/link";
1112
import { useRouter } from "next/navigation";
12-
import { ReactNode } from "react";
13+
import { ReactNode, useEffect, useRef } from "react";
1314

1415
export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) {
1516
return (
@@ -75,6 +76,30 @@ export function ChatAreaContent(props: Props) {
7576
);
7677

7778
const router = useRouter();
79+
const streaming = useStreamingChat();
80+
const isStreamingThis = streaming.streamingChatId === chatId;
81+
const hasRefreshedRef = useRef(false);
82+
83+
useEffect(() => {
84+
if (!isStreamingThis || streaming.isStreaming) {
85+
hasRefreshedRef.current = false;
86+
return;
87+
}
88+
// ストリーミングが終了した
89+
if (chatData.messages.length > 0) {
90+
// DBにデータが揃った → ストリーミング状態を解除
91+
streaming.clearStreaming();
92+
hasRefreshedRef.current = false;
93+
} else if (!hasRefreshedRef.current) {
94+
// DBがまだ更新されていない → 再読み込みして最新データを取得
95+
hasRefreshedRef.current = true;
96+
router.refresh();
97+
}
98+
// eslint-disable-next-line react-hooks/exhaustive-deps
99+
}, [isStreamingThis, streaming.isStreaming, chatData.messages.length, streaming.clearStreaming, router]);
100+
101+
// ストリーミング中または完了直後(DBリフレッシュ前)はストリーミングコンテンツを表示
102+
const showStreaming = isStreamingThis;
78103

79104
return (
80105
<>
@@ -161,46 +186,65 @@ export function ChatAreaContent(props: Props) {
161186
</button>
162187
</div>
163188
<div className="divider" />
164-
{messagesAndDiffs.map((msg, index) =>
165-
msg.type === "message" ? (
166-
msg.role === "user" ? (
167-
<div key={index} className="chat chat-end">
168-
<div
169-
className="chat-bubble p-0.5! bg-secondary/30"
170-
style={{ maxWidth: "100%", wordBreak: "break-word" }}
171-
>
189+
{showStreaming ? (
190+
<>
191+
<div className="chat chat-end">
192+
<div
193+
className="chat-bubble p-0.5! bg-secondary/30"
194+
style={{ maxWidth: "100%", wordBreak: "break-word" }}
195+
>
196+
<StyledMarkdown content={streaming.userQuestion} />
197+
</div>
198+
</div>
199+
<div className="">
200+
<StyledMarkdown content={streaming.streamingContent} />
201+
{streaming.isStreaming && (
202+
<span className="loading loading-dots loading-sm" />
203+
)}
204+
</div>
205+
</>
206+
) : (
207+
messagesAndDiffs.map((msg, index) =>
208+
msg.type === "message" ? (
209+
msg.role === "user" ? (
210+
<div key={index} className="chat chat-end">
211+
<div
212+
className="chat-bubble p-0.5! bg-secondary/30"
213+
style={{ maxWidth: "100%", wordBreak: "break-word" }}
214+
>
215+
<StyledMarkdown content={msg.content} />
216+
</div>
217+
</div>
218+
) : msg.role === "ai" ? (
219+
<div key={index} className="">
172220
<StyledMarkdown content={msg.content} />
173221
</div>
174-
</div>
175-
) : msg.role === "ai" ? (
176-
<div key={index} className="">
177-
<StyledMarkdown content={msg.content} />
178-
</div>
222+
) : (
223+
<div key={index} className="text-error">
224+
{msg.content}
225+
</div>
226+
)
179227
) : (
180-
<div key={index} className="text-error">
181-
{msg.content}
182-
</div>
183-
)
184-
) : (
185-
<div
186-
key={index}
187-
className={clsx(
188-
"bg-base-300 rounded-lg border border-2 border-secondary/50"
189-
)}
190-
>
191-
{/* pb-0だとmargin collapsingが起きて変な隙間が空く */}
192-
<del
228+
<div
229+
key={index}
193230
className={clsx(
194-
"block p-2 pb-[1px] bg-error/10",
195-
"line-through decoration-[color-mix(in_oklab,var(--color-error)_70%,currentColor)]"
231+
"bg-base-300 rounded-lg border border-2 border-secondary/50"
196232
)}
197233
>
198-
<StyledMarkdown content={msg.search} />
199-
</del>
200-
<ins className="block no-underline p-2 pt-[1px] bg-success/10">
201-
<StyledMarkdown content={msg.replace} />
202-
</ins>
203-
</div>
234+
{/* pb-0だとmargin collapsingが起きて変な隙間が空く */}
235+
<del
236+
className={clsx(
237+
"block p-2 pb-[1px] bg-error/10",
238+
"line-through decoration-[color-mix(in_oklab,var(--color-error)_70%,currentColor)]"
239+
)}
240+
>
241+
<StyledMarkdown content={msg.search} />
242+
</del>
243+
<ins className="block no-underline p-2 pt-[1px] bg-success/10">
244+
<StyledMarkdown content={msg.replace} />
245+
</ins>
246+
</div>
247+
)
204248
)
205249
)}
206250
</>

app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx

Lines changed: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { useState, FormEvent, useEffect } from "react";
99
// import { getLanguageName } from "../pagesList";
1010
import { DynamicMarkdownSection } from "./pageContent";
1111
import { useEmbedContext } from "@/terminal/embedContext";
12-
import { askAI } from "@/actions/chatActions";
1312
import { PagePath } from "@/lib/docs";
1413
import { useRouter } from "next/navigation";
14+
import { useStreamingChat } from "@/(docs)/streamingChatContext";
1515

1616
interface ChatFormProps {
1717
path: PagePath;
@@ -30,6 +30,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
3030
const { files, replOutputs, execResults } = useEmbedContext();
3131

3232
const router = useRouter();
33+
const streamingChat = useStreamingChat();
3334

3435
// const documentContentInView = sectionContent
3536
// .filter((s) => s.inView)
@@ -64,39 +65,101 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
6465
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
6566
e.preventDefault();
6667
setIsLoading(true);
67-
setErrorMessage(null); // Clear previous error message
68+
setErrorMessage(null);
6869

6970
const userQuestion = inputValue;
70-
// if (!userQuestion && exampleData) {
71-
// // 質問が空欄なら、質問例を使用
72-
// userQuestion =
73-
// exampleData[Math.floor(exampleChoice * exampleData.length)];
74-
// setInputValue(userQuestion);
75-
// }
76-
77-
const result = await askAI({
78-
path,
79-
userQuestion,
80-
sectionContent,
81-
replOutputs,
82-
files,
83-
execResults,
84-
});
8571

86-
if (result.error !== null) {
87-
setErrorMessage(result.error);
88-
console.log(result.error);
89-
} else {
90-
document.getElementById(result.chat.sectionId)?.scrollIntoView({
91-
behavior: "smooth",
72+
let response: Response;
73+
try {
74+
response = await fetch("/api/chat", {
75+
method: "POST",
76+
headers: { "Content-Type": "application/json" },
77+
body: JSON.stringify({
78+
path,
79+
userQuestion,
80+
sectionContent,
81+
replOutputs,
82+
files,
83+
execResults,
84+
}),
9285
});
93-
router.push(`/chat/${result.chat.chatId}`, { scroll: false });
94-
router.refresh();
95-
setInputValue("");
96-
close();
86+
} catch {
87+
setErrorMessage("AIへの接続に失敗しました");
88+
setIsLoading(false);
89+
return;
90+
}
91+
92+
if (!response.ok) {
93+
setErrorMessage(`エラーが発生しました (${response.status})`);
94+
setIsLoading(false);
95+
return;
9796
}
9897

99-
setIsLoading(false);
98+
const reader = response.body!.getReader();
99+
const decoder = new TextDecoder();
100+
let buffer = "";
101+
let navigated = false;
102+
103+
// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
104+
const readStream = async () => {
105+
while (true) {
106+
let result: ReadableStreamReadResult<Uint8Array>;
107+
try {
108+
result = await reader.read();
109+
} catch (err) {
110+
console.error("Stream connection interrupted:", err);
111+
break;
112+
}
113+
const { done, value } = result;
114+
if (done) break;
115+
116+
buffer += decoder.decode(value, { stream: true });
117+
const lines = buffer.split("\n");
118+
buffer = lines.pop() ?? "";
119+
120+
for (const line of lines) {
121+
if (!line.trim()) continue;
122+
try {
123+
const event = JSON.parse(line) as
124+
| { type: "chat"; chatId: string; sectionId: string }
125+
| { type: "chunk"; text: string }
126+
| { type: "done" }
127+
| { type: "error"; message: string };
128+
129+
if (event.type === "chat") {
130+
streamingChat.startStreaming(event.chatId, userQuestion);
131+
document.getElementById(event.sectionId)?.scrollIntoView({
132+
behavior: "smooth",
133+
});
134+
router.push(`/chat/${event.chatId}`, { scroll: false });
135+
navigated = true;
136+
setIsLoading(false);
137+
setInputValue("");
138+
close();
139+
} else if (event.type === "chunk") {
140+
streamingChat.appendChunk(event.text);
141+
} else if (event.type === "done") {
142+
streamingChat.finishStreaming();
143+
} else if (event.type === "error") {
144+
if (!navigated) {
145+
setErrorMessage(event.message);
146+
setIsLoading(false);
147+
}
148+
streamingChat.finishStreaming();
149+
}
150+
} catch {
151+
// ignore JSON parse errors
152+
}
153+
}
154+
}
155+
};
156+
157+
// ストリーム読み込みはバックグラウンドで継続(awaitしない)
158+
readStream().catch((err) => {
159+
console.error("Stream reading failed:", err);
160+
// ナビゲーション後のエラーはストリーミングを終了してローディングを止める
161+
streamingChat.finishStreaming();
162+
});
100163
};
101164

102165
return (

app/(docs)/layout.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactNode } from "react";
22
import { ChatAreaStateProvider } from "./chatAreaState";
3+
import { StreamingChatProvider } from "./streamingChatContext";
34

45
// app/(workspace)/layout.tsx
56
export default function WorkspaceLayout({
@@ -12,15 +13,17 @@ export default function WorkspaceLayout({
1213
chat: ReactNode;
1314
}) {
1415
return (
15-
<ChatAreaStateProvider>
16-
<div className="w-full flex flex-row">
17-
{docs}
16+
<StreamingChatProvider>
17+
<ChatAreaStateProvider>
18+
<div className="w-full flex flex-row">
19+
{docs}
1820

19-
{chat}
21+
{chat}
2022

21-
{/* children(page.tsx)は今回は使わないか、背景として利用 */}
22-
{children}
23-
</div>
24-
</ChatAreaStateProvider>
23+
{/* children(page.tsx)は今回は使わないか、背景として利用 */}
24+
{children}
25+
</div>
26+
</ChatAreaStateProvider>
27+
</StreamingChatProvider>
2528
);
2629
}

0 commit comments

Comments
 (0)