Skip to content

Commit afd29a8

Browse files
committed
Merge branch 'main' into generate-question-examples
2 parents c0781ac + 28504a9 commit afd29a8

File tree

23 files changed

+732
-327
lines changed

23 files changed

+732
-327
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: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { useState, FormEvent, useEffect } from "react";
77
// QuestionExampleParams,
88
// } from "../actions/questionExample";
99
// import { getLanguageName } from "../pagesList";
10-
import { DynamicMarkdownSection } from "./pageContent";
1110
import { useEmbedContext } from "@/terminal/embedContext";
12-
import { askAI } from "@/actions/chatActions";
13-
import { PagePath } from "@/lib/docs";
11+
import { DynamicMarkdownSection, PagePath } from "@/lib/docs";
1412
import { useRouter } from "next/navigation";
13+
import { ChatStreamEvent } from "@/api/chat/route";
14+
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
1515

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

3030
const router = useRouter();
31+
const streamingChatContext = useStreamingChatContext();
3132

3233
const exampleData = sectionContent
3334
.filter((s) => s.inView)
@@ -46,42 +47,108 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
4647
}, [exampleChoice]);
4748

4849
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
50+
4951
let userQuestion = inputValue;
5052
if (!userQuestion && exampleData.length > 0 && exampleChoice) {
5153
// 質問が空欄なら、質問例を使用
5254
userQuestion =
5355
exampleData[Math.floor(exampleChoice * exampleData.length)];
5456
setInputValue(userQuestion);
5557
}
56-
if (userQuestion) {
57-
e.preventDefault();
58-
setIsLoading(true);
59-
setErrorMessage(null); // Clear previous error message
60-
61-
const result = await askAI({
62-
path,
63-
userQuestion,
64-
sectionContent,
65-
replOutputs,
66-
files,
67-
execResults,
68-
});
58+
if (!userQuestion) {
59+
return;
60+
}
6961

62+
e.preventDefault();
63+
setIsLoading(true);
64+
setErrorMessage(null); // Clear previous error message
7065

71-
if (result.error !== null) {
72-
setErrorMessage(result.error);
73-
console.log(result.error);
74-
} else {
75-
document.getElementById(result.chat.sectionId)?.scrollIntoView({
76-
behavior: "smooth",
77-
});
78-
router.push(`/chat/${result.chat.chatId}`, { scroll: false });
79-
router.refresh();
80-
setInputValue("");
81-
close();
82-
}
66+
let response: Response;
67+
try {
68+
response = await fetch("/api/chat", {
69+
method: "POST",
70+
headers: { "Content-Type": "application/json" },
71+
body: JSON.stringify({
72+
path,
73+
userQuestion,
74+
sectionContent,
75+
replOutputs,
76+
files,
77+
execResults,
78+
}),
79+
});
80+
} catch {
81+
setErrorMessage("AIへの接続に失敗しました");
82+
setIsLoading(false);
83+
return;
84+
}
85+
86+
if (!response.ok) {
87+
setErrorMessage(`エラーが発生しました (${response.status})`);
8388
setIsLoading(false);
89+
return;
8490
}
91+
92+
const reader = response.body!.getReader();
93+
const decoder = new TextDecoder();
94+
let buffer = "";
95+
let navigated = false;
96+
97+
// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
98+
void (async () => {
99+
try {
100+
while (true) {
101+
const result = await reader.read();
102+
const { done, value } = result;
103+
if (done) break;
104+
105+
buffer += decoder.decode(value, { stream: true });
106+
const lines = buffer.split("\n");
107+
buffer = lines.pop() ?? "";
108+
109+
for (const line of lines) {
110+
if (!line.trim()) continue;
111+
try {
112+
const event = JSON.parse(line) as ChatStreamEvent;
113+
114+
if (event.type === "chat") {
115+
streamingChatContext.startStreaming(event.chatId);
116+
document.getElementById(event.sectionId)?.scrollIntoView({
117+
behavior: "smooth",
118+
});
119+
router.push(`/chat/${event.chatId}`, { scroll: false });
120+
router.refresh();
121+
navigated = true;
122+
setIsLoading(false);
123+
setInputValue("");
124+
close();
125+
} else if (event.type === "chunk") {
126+
streamingChatContext.appendChunk(event.text);
127+
} else if (event.type === "done") {
128+
streamingChatContext.finishStreaming();
129+
router.refresh();
130+
} else if (event.type === "error") {
131+
if (!navigated) {
132+
setErrorMessage(event.message);
133+
setIsLoading(false);
134+
}
135+
streamingChatContext.finishStreaming();
136+
}
137+
} catch {
138+
// ignore JSON parse errors
139+
}
140+
}
141+
}
142+
} catch (err) {
143+
console.error("Stream reading failed:", err);
144+
// ナビゲーション後のエラーはストリーミングを終了してローディングを止める
145+
if (!navigated) {
146+
setErrorMessage(String(err));
147+
setIsLoading(false);
148+
}
149+
streamingChatContext.finishStreaming();
150+
}
151+
})();
85152
};
86153

87154
return (

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,18 @@ import { useSidebarMdContext } from "@/sidebar";
77
import clsx from "clsx";
88
import { PageTransition } from "./pageTransition";
99
import {
10+
DynamicMarkdownSection,
1011
LanguageEntry,
1112
MarkdownSection,
1213
PageEntry,
1314
PagePath,
1415
SectionId,
1516
} from "@/lib/docs";
16-
import { ReplacedRange } from "@/markdown/multiHighlight";
1717
import { Heading } from "@/markdown/heading";
1818
import Link from "next/link";
1919
import { useChatId } from "@/(docs)/chatAreaState";
2020
import { ChatWithMessages } from "@/lib/chatHistory";
2121

22-
/**
23-
* MarkdownSectionに追加で、動的な情報を持たせる
24-
*/
25-
export interface DynamicMarkdownSection extends MarkdownSection {
26-
/**
27-
* ユーザーが今そのセクションを読んでいるかどうか
28-
*/
29-
inView: boolean;
30-
/**
31-
* チャットの会話を元にAIが書き換えた後の内容
32-
*/
33-
replacedContent: string;
34-
replacedRange: ReplacedRange[];
35-
}
36-
3722
interface PageContentProps {
3823
splitMdContent: MarkdownSection[];
3924
langEntry: LanguageEntry;

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+
}

app/actions/deleteChat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"use server";
22

3+
import { z } from "zod";
34
import { deleteChat, initContext } from "@/lib/chatHistory";
45

56
export async function deleteChatAction(chatId: string) {
7+
chatId = z.uuid().parse(chatId);
68
const ctx = await initContext();
79
await deleteChat(chatId, ctx);
810
}

app/actions/getRedirectFromChat.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"use server";
22

3+
import { z } from "zod";
34
import { initContext } from "@/lib/chatHistory";
45
import { LangId, PageSlug } from "@/lib/docs";
56
import { chat, section } from "@/schema/chat";
67
import { and, eq } from "drizzle-orm";
78

89
export async function getRedirectFromChat(chatId: string): Promise<string> {
10+
chatId = z.uuid().parse(chatId);
11+
912
const { drizzle, userId } = await initContext();
1013
if (!userId) {
1114
throw new Error("Not authenticated");

0 commit comments

Comments
 (0)