Skip to content

Commit 7c16a3e

Browse files
committed
ストリーミングで表示するのはaiメッセージのみ
1 parent 87bd327 commit 7c16a3e

File tree

6 files changed

+142
-483
lines changed

6 files changed

+142
-483
lines changed

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

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

33
import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
4-
import { useStreamingChat } from "@/(docs)/streamingChatContext";
4+
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
55
import { deleteChatAction } from "@/actions/deleteChat";
66
import { ChatWithMessages } from "@/lib/chatHistory";
77
import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
@@ -12,7 +12,10 @@ import Link from "next/link";
1212
import { useRouter } from "next/navigation";
1313
import { ReactNode, useEffect, useRef } from "react";
1414

15-
export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) {
15+
export function ChatAreaContainer(props: {
16+
chatId: string;
17+
children: ReactNode;
18+
}) {
1619
return (
1720
<aside
1821
className={clsx(
@@ -76,30 +79,8 @@ export function ChatAreaContent(props: Props) {
7679
);
7780

7881
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;
82+
const streamingChatContext = useStreamingChatContext();
83+
const isStreamingThis = streamingChatContext.chatId === chatId;
10384

10485
return (
10586
<>
@@ -186,67 +167,54 @@ export function ChatAreaContent(props: Props) {
186167
</button>
187168
</div>
188169
<div className="divider" />
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="">
170+
{messagesAndDiffs.map((msg, index) =>
171+
msg.type === "message" ? (
172+
msg.role === "user" ? (
173+
<div key={index} className="chat chat-end">
174+
<div
175+
className="chat-bubble p-0.5! bg-secondary/30"
176+
style={{ maxWidth: "100%", wordBreak: "break-word" }}
177+
>
220178
<StyledMarkdown content={msg.content} />
221179
</div>
222-
) : (
223-
<div key={index} className="text-error">
224-
{msg.content}
225-
</div>
226-
)
180+
</div>
181+
) : msg.role === "ai" ? (
182+
<div key={index} className="">
183+
<StyledMarkdown content={msg.content} />
184+
</div>
227185
) : (
228-
<div
229-
key={index}
186+
<div key={index} className="text-error">
187+
{msg.content}
188+
</div>
189+
)
190+
) : (
191+
<div
192+
key={index}
193+
className={clsx(
194+
"bg-base-300 rounded-lg border border-2 border-secondary/50"
195+
)}
196+
>
197+
{/* pb-0だとmargin collapsingが起きて変な隙間が空く */}
198+
<del
230199
className={clsx(
231-
"bg-base-300 rounded-lg border border-2 border-secondary/50"
200+
"block p-2 pb-[1px] bg-error/10",
201+
"line-through decoration-[color-mix(in_oklab,var(--color-error)_70%,currentColor)]"
232202
)}
233203
>
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-
)
204+
<StyledMarkdown content={msg.search} />
205+
</del>
206+
<ins className="block no-underline p-2 pt-[1px] bg-success/10">
207+
<StyledMarkdown content={msg.replace} />
208+
</ins>
209+
</div>
248210
)
249211
)}
212+
{isStreamingThis && (
213+
<div className="">
214+
<StyledMarkdown content={streamingChatContext.content} />
215+
<span className="loading loading-dots loading-sm" />
216+
</div>
217+
)}
250218
</>
251219
);
252220
}

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

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { DynamicMarkdownSection } from "./pageContent";
1111
import { useEmbedContext } from "@/terminal/embedContext";
1212
import { PagePath } from "@/lib/docs";
1313
import { useRouter } from "next/navigation";
14-
import { useStreamingChat } from "@/(docs)/streamingChatContext";
14+
import { ChatStreamEvent } from "@/api/chat/route";
15+
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
1516

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

3233
const router = useRouter();
33-
const streamingChat = useStreamingChat();
34+
const streamingChatContext = useStreamingChatContext();
3435

3536
// const documentContentInView = sectionContent
3637
// .filter((s) => s.inView)
@@ -101,65 +102,60 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
101102
let navigated = false;
102103

103104
// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
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);
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;
146129
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();
147143
}
148-
streamingChat.finishStreaming();
144+
} catch {
145+
// ignore JSON parse errors
149146
}
150-
} catch {
151-
// ignore JSON parse errors
152147
}
153148
}
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();
154157
}
155-
};
156-
157-
// ストリーム読み込みはバックグラウンドで継続(awaitしない)
158-
readStream().catch((err) => {
159-
console.error("Stream reading failed:", err);
160-
// ナビゲーション後のエラーはストリーミングを終了してローディングを止める
161-
streamingChat.finishStreaming();
162-
});
158+
})();
163159
};
164160

165161
return (

app/(docs)/streamingChatContext.tsx

Lines changed: 34 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,54 @@
11
"use client";
22

3-
import { createContext, ReactNode, useContext, useState } from "react";
4-
5-
export interface StreamingChatState {
6-
streamingChatId: string | null;
7-
userQuestion: string;
8-
streamingContent: string;
9-
isStreaming: boolean;
10-
}
11-
12-
interface StreamingChatActions {
13-
startStreaming: (chatId: string, userQuestion: string) => void;
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;
1415
appendChunk: (chunk: string) => void;
1516
finishStreaming: () => void;
16-
clearStreaming: () => void;
1717
}
1818

19-
const StreamingChatContext = createContext<
20-
StreamingChatState & StreamingChatActions
21-
>(null!);
19+
const StreamingChatContext = createContext<StreamingChatContextData>(null!);
2220

23-
export function useStreamingChat() {
21+
export function useStreamingChatContext() {
2422
return useContext(StreamingChatContext);
2523
}
2624

2725
export function StreamingChatProvider({ children }: { children: ReactNode }) {
28-
const [state, setState] = useState<StreamingChatState>({
29-
streamingChatId: null,
30-
userQuestion: "",
31-
streamingContent: "",
32-
isStreaming: false,
33-
});
34-
35-
const startStreaming = (chatId: string, userQuestion: string) => {
36-
setState({
37-
streamingChatId: chatId,
38-
userQuestion,
39-
streamingContent: "",
40-
isStreaming: true,
41-
});
42-
};
26+
const [chatId, setChatId] = useState<string | null>(null);
27+
const [content, setContent] = useState("");
4328

44-
const appendChunk = (chunk: string) => {
45-
setState((prev) => ({
46-
...prev,
47-
streamingContent: prev.streamingContent + chunk,
48-
}));
49-
};
29+
const startStreaming = useCallback((chatId: string) => {
30+
setContent("");
31+
setChatId(chatId);
32+
}, []);
5033

51-
const finishStreaming = () => {
52-
setState((prev) => ({ ...prev, isStreaming: false }));
53-
};
34+
const appendChunk = useCallback((chunk: string) => {
35+
setContent((prev) => prev + chunk);
36+
}, []);
5437

55-
const clearStreaming = () => {
56-
setState({
57-
streamingChatId: null,
58-
userQuestion: "",
59-
streamingContent: "",
60-
isStreaming: false,
61-
});
62-
};
38+
const finishStreaming = useCallback(() => {
39+
setChatId(null);
40+
setContent("");
41+
}, []);
6342

6443
return (
6544
<StreamingChatContext.Provider
66-
value={{ ...state, startStreaming, appendChunk, finishStreaming, clearStreaming }}
45+
value={{
46+
chatId,
47+
content,
48+
startStreaming,
49+
appendChunk,
50+
finishStreaming,
51+
}}
6752
>
6853
{children}
6954
</StreamingChatContext.Provider>

0 commit comments

Comments
 (0)