Skip to content

Commit fc88869

Browse files
authored
Merge pull request #194 from ut-code/copilot/add-streaming-ai-responses
feat: Stream AI responses in real-time via Route Handler
2 parents e458dd3 + 71e6986 commit fc88869

File tree

8 files changed

+493
-265
lines changed

8 files changed

+493
-265
lines changed

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

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

33
import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
4+
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
45
import { deleteChatAction } from "@/actions/deleteChat";
56
import { ChatWithMessages } from "@/lib/chatHistory";
67
import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
@@ -11,7 +12,10 @@ import Link from "next/link";
1112
import { useRouter } from "next/navigation";
1213
import { ReactNode } from "react";
1314

14-
export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) {
15+
export function ChatAreaContainer(props: {
16+
chatId: string;
17+
children: ReactNode;
18+
}) {
1519
return (
1620
<aside
1721
className={clsx(
@@ -75,6 +79,8 @@ export function ChatAreaContent(props: Props) {
7579
);
7680

7781
const router = useRouter();
82+
const streamingChatContext = useStreamingChatContext();
83+
const isStreamingThis = streamingChatContext.chatId === chatId;
7884

7985
return (
8086
<>
@@ -203,6 +209,12 @@ export function ChatAreaContent(props: Props) {
203209
</div>
204210
)
205211
)}
212+
{isStreamingThis && (
213+
<div className="">
214+
<StyledMarkdown content={streamingChatContext.content} />
215+
<span className="loading loading-dots loading-sm" />
216+
</div>
217+
)}
206218
</>
207219
);
208220
}

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

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ 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 { ChatStreamEvent } from "@/api/chat/route";
15+
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
1516

1617
interface ChatFormProps {
1718
path: PagePath;
@@ -30,6 +31,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
3031
const { files, replOutputs, execResults } = useEmbedContext();
3132

3233
const router = useRouter();
34+
const streamingChatContext = useStreamingChatContext();
3335

3436
// const documentContentInView = sectionContent
3537
// .filter((s) => s.inView)
@@ -64,39 +66,96 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
6466
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
6567
e.preventDefault();
6668
setIsLoading(true);
67-
setErrorMessage(null); // Clear previous error message
69+
setErrorMessage(null);
6870

6971
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-
});
85-
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+
73+
let response: Response;
74+
try {
75+
response = await fetch("/api/chat", {
76+
method: "POST",
77+
headers: { "Content-Type": "application/json" },
78+
body: JSON.stringify({
79+
path,
80+
userQuestion,
81+
sectionContent,
82+
replOutputs,
83+
files,
84+
execResults,
85+
}),
9286
});
93-
router.push(`/chat/${result.chat.chatId}`, { scroll: false });
94-
router.refresh();
95-
setInputValue("");
96-
close();
87+
} catch {
88+
setErrorMessage("AIへの接続に失敗しました");
89+
setIsLoading(false);
90+
return;
91+
}
92+
93+
if (!response.ok) {
94+
setErrorMessage(`エラーが発生しました (${response.status})`);
95+
setIsLoading(false);
96+
return;
9797
}
9898

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

102161
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
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
3+
import {
4+
createContext,
5+
ReactNode,
6+
useCallback,
7+
useContext,
8+
useState,
9+
} from "react";
10+
11+
interface StreamingChatContextData {
12+
chatId: string | null;
13+
content: string;
14+
startStreaming: (chatId: string) => void;
15+
appendChunk: (chunk: string) => void;
16+
finishStreaming: () => void;
17+
}
18+
19+
const StreamingChatContext = createContext<StreamingChatContextData>(null!);
20+
21+
export function useStreamingChatContext() {
22+
return useContext(StreamingChatContext);
23+
}
24+
25+
export function StreamingChatProvider({ children }: { children: ReactNode }) {
26+
const [chatId, setChatId] = useState<string | null>(null);
27+
const [content, setContent] = useState("");
28+
29+
const startStreaming = useCallback((chatId: string) => {
30+
setContent("");
31+
setChatId(chatId);
32+
}, []);
33+
34+
const appendChunk = useCallback((chunk: string) => {
35+
setContent((prev) => prev + chunk);
36+
}, []);
37+
38+
const finishStreaming = useCallback(() => {
39+
setChatId(null);
40+
setContent("");
41+
}, []);
42+
43+
return (
44+
<StreamingChatContext.Provider
45+
value={{
46+
chatId,
47+
content,
48+
startStreaming,
49+
appendChunk,
50+
finishStreaming,
51+
}}
52+
>
53+
{children}
54+
</StreamingChatContext.Provider>
55+
);
56+
}

0 commit comments

Comments
 (0)