Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/(docs)/@chat/chat/[chatId]/chatArea.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
import { deleteChatAction } from "@/actions/deleteChat";
import { ChatWithMessages } from "@/lib/chatHistory";
import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
Expand All @@ -11,7 +12,10 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ReactNode } from "react";

export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) {
export function ChatAreaContainer(props: {
chatId: string;
children: ReactNode;
}) {
return (
<aside
className={clsx(
Expand Down Expand Up @@ -75,6 +79,8 @@ export function ChatAreaContent(props: Props) {
);

const router = useRouter();
const streamingChatContext = useStreamingChatContext();
const isStreamingThis = streamingChatContext.chatId === chatId;

return (
<>
Expand Down Expand Up @@ -203,6 +209,12 @@ export function ChatAreaContent(props: Props) {
</div>
)
)}
{isStreamingThis && (
<div className="">
<StyledMarkdown content={streamingChatContext.content} />
<span className="loading loading-dots loading-sm" />
</div>
)}
</>
);
}
117 changes: 88 additions & 29 deletions app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ 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 { ChatStreamEvent } from "@/api/chat/route";
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";

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

const router = useRouter();
const streamingChatContext = useStreamingChatContext();

// const documentContentInView = sectionContent
// .filter((s) => s.inView)
Expand Down Expand Up @@ -64,39 +66,96 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
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;

// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
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();
}
} catch {
// ignore JSON parse errors
}
}
}
} catch (err) {
console.error("Stream reading failed:", err);
// ナビゲーション後のエラーはストリーミングを終了してローディングを止める
if (!navigated) {
setErrorMessage(String(err));
setIsLoading(false);
}
streamingChatContext.finishStreaming();
}
})();
};

return (
Expand Down
19 changes: 11 additions & 8 deletions app/(docs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactNode } from "react";
import { ChatAreaStateProvider } from "./chatAreaState";
import { StreamingChatProvider } from "./streamingChatContext";

// app/(workspace)/layout.tsx
export default function WorkspaceLayout({
Expand All @@ -12,15 +13,17 @@ export default function WorkspaceLayout({
chat: ReactNode;
}) {
return (
<ChatAreaStateProvider>
<div className="w-full flex flex-row">
{docs}
<StreamingChatProvider>
<ChatAreaStateProvider>
<div className="w-full flex flex-row">
{docs}

{chat}
{chat}

{/* children(page.tsx)は今回は使わないか、背景として利用 */}
{children}
</div>
</ChatAreaStateProvider>
{/* children(page.tsx)は今回は使わないか、背景として利用 */}
{children}
</div>
</ChatAreaStateProvider>
</StreamingChatProvider>
);
}
56 changes: 56 additions & 0 deletions app/(docs)/streamingChatContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

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

const StreamingChatContext = createContext<StreamingChatContextData>(null!);

export function useStreamingChatContext() {
return useContext(StreamingChatContext);
}

export function StreamingChatProvider({ children }: { children: ReactNode }) {
const [chatId, setChatId] = useState<string | null>(null);
const [content, setContent] = useState("");

const startStreaming = useCallback((chatId: string) => {
setContent("");
setChatId(chatId);
}, []);

const appendChunk = useCallback((chunk: string) => {
setContent((prev) => prev + chunk);
}, []);

const finishStreaming = useCallback(() => {
setChatId(null);
setContent("");
}, []);

return (
<StreamingChatContext.Provider
value={{
chatId,
content,
startStreaming,
appendChunk,
finishStreaming,
}}
>
{children}
</StreamingChatContext.Provider>
);
}
Loading
Loading