Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
15 changes: 15 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run tsc

test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run test

check-docs:
runs-on: ubuntu-latest
strategy:
Expand Down
75 changes: 72 additions & 3 deletions app/(docs)/@chat/chat/[chatId]/chatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
import { useSendChat } from "@/(docs)/useSendChat";
import { deleteChatAction } from "@/actions/deleteChat";
import { ChatWithMessages } from "@/lib/chatHistory";
import { LangId, MarkdownSection, PageSlug } from "@/lib/docs";
import { DynamicMarkdownSection, LangId, MarkdownSection, PageSlug } from "@/lib/docs";
import { Heading } from "@/markdown/heading";
import { StyledMarkdown } from "@/markdown/markdown";
import { usePagesListForLang } from "@/pagesListContext";
Expand Down Expand Up @@ -74,9 +75,17 @@ interface Props {
langId: LangId;
pageSlug: PageSlug;
targetSection: MarkdownSection | undefined;
priorSectionContent: DynamicMarkdownSection[];
}
export function ChatAreaContent(props: Props) {
const { chatId, chatData, langId, pageSlug, targetSection } = props;
const {
chatId,
chatData,
langId,
pageSlug,
targetSection,
priorSectionContent,
} = props;

const langEntry = usePagesListForLang(langId);
const pageEntry = langEntry?.pages.find((p) => p.slug === pageSlug);
Expand All @@ -92,6 +101,23 @@ export function ChatAreaContent(props: Props) {
const router = useRouter();
const streamingChatContext = useStreamingChatContext();
const isStreamingThis = streamingChatContext.chatId === chatId;
const { sendChat, isLoading: isRegenerating } = useSendChat();

const handleRegenerate = async () => {
if (!confirm("このチャットを削除して再生成してもよろしいですか?")) {
return;
}
const firstUserMsg = chatData.messages.find((m) => m.role === "user");
const userQuestion = firstUserMsg ? firstUserMsg.content : chatData.title;

await sendChat({
path: { lang: langId, page: pageSlug },
userQuestion,
questionScope: "page",
sectionContent: priorSectionContent,
deleteChatOnCreated: chatId,
});
};

return (
<>
Expand All @@ -117,12 +143,55 @@ export function ChatAreaContent(props: Props) {
</li>
</ul>
</div>
<div className="flex flex-wrap items-center">
<div className="flex flex-wrap items-center gap-2">
<div className="flex-1 text-sm opacity-40" suppressHydrationWarning>
{chatData.createdAt.toLocaleString()}
</div>
<button
className="btn btn-secondary btn-soft btn-sm"
disabled={isStreamingThis || isRegenerating}
onClick={handleRegenerate}
>
<svg
className={clsx("w-4 h-4", isRegenerating && "animate-spin")}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.06189 13C4.55399 16.944 7.92083 20 12 20C15.5463 20 18.5721 17.7719 19.5714 14.619"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.9381 11C19.446 7.05601 16.0792 4 12 4C8.45371 4 5.42788 6.22811 4.42857 9.38095"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 14.619H19.5714V20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 9.38095H4.42857V4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
再生成
</button>
<button
className="btn btn-error btn-soft btn-sm"
disabled={isStreamingThis || isRegenerating}
onClick={async () => {
if (confirm("このチャットを削除してもよろしいですか?")) {
await deleteChatAction(chatId);
Expand Down
18 changes: 18 additions & 0 deletions app/(docs)/@chat/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
applyChatDiff,
cacheKeyForChat,
ChatWithMessages,
getChatFromCache,
getChatOne,
initContext,
} from "@/lib/chatHistory";
Expand Down Expand Up @@ -37,9 +39,24 @@ export default async function ChatPage({
LangId,
PageSlug,
];
const path = { lang: langId, page: pageSlug };
const sections = await getMarkdownSections(langId, pageSlug);
const targetSection = sections.find((sec) => sec.id === chatData.sectionId);

const chatHistories = await getChatFromCache(path, context.userId);
const targetCreatedAt = new Date(chatData.createdAt).getTime();
const priorChatHistories = chatHistories.filter(
(c) => new Date(c.createdAt).getTime() < targetCreatedAt
);
const priorSectionContent = (
await applyChatDiff(sections, priorChatHistories, {
fallbackToPastVersion: false,
})
).map((sec) => ({
...sec,
inView: sec.id === chatData.sectionId,
}));

return (
<ChatAreaContainer chatId={chatId}>
<ChatAreaContent
Expand All @@ -48,6 +65,7 @@ export default async function ChatPage({
langId={langId}
pageSlug={pageSlug}
targetSection={targetSection}
priorSectionContent={priorSectionContent}
/>
</ChatAreaContainer>
);
Expand Down
169 changes: 13 additions & 156 deletions app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import {
useState,
FormEvent,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
// import useSWR from "swr";
Expand All @@ -14,13 +12,8 @@ import {
// QuestionExampleParams,
// } from "../actions/questionExample";
// import { getLanguageName } from "../pagesList";
import { useEmbedContext } from "@/terminal/embedContext";
import { DynamicMarkdownSection, PagePath } from "@/lib/docs";
import { usePathname, useRouter } from "next/navigation";
import { ChatStreamEvent } from "@/api/chat/route";
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
import { revalidateChatAction } from "@/actions/revalidateChat";
import { captureException } from "@sentry/nextjs";
import { useSendChat } from "@/(docs)/useSendChat";

interface ChatFormProps {
path: PagePath;
Expand All @@ -35,44 +28,8 @@ export function ChatForm({ path, langName, sectionContent, close }: ChatFormProp
const [questionScope, setQuestionScope] = useState<"page" | "language">(
"page"
);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const { files, replOutputs, execResults } = useEmbedContext();

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

const pathname = usePathname();
const pendingRouterPushTarget = useRef<null | string>(null);
const pendingRouterPushResolver = useRef<null | (() => void)>(null);
// router.pushの完了を待つ関数。pathnameの変化でページ遷移の完了を検知し、解決する。
const asyncRouterPush = useCallback(
(url: string, options?: { scroll?: boolean }) => {
if (pendingRouterPushTarget.current) {
console.error(
"Already navigating to",
pendingRouterPushTarget.current,
"can't navigate to",
url
);
return;
}
pendingRouterPushTarget.current = url;
return new Promise<void>((resolve) => {
pendingRouterPushResolver.current = resolve;
router.push(url, options);
});
},
[router]
);
useEffect(() => {
if (pendingRouterPushTarget.current === pathname) {
pendingRouterPushResolver.current?.();
pendingRouterPushTarget.current = null;
pendingRouterPushResolver.current = null;
}
}, [pathname]);
const { sendChat, isLoading, errorMessage } = useSendChat();

const exampleData = useMemo(
() =>
Expand All @@ -96,6 +53,7 @@ export function ChatForm({ path, langName, sectionContent, close }: ChatFormProp
}, [exampleChoice]);

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
let userQuestion = inputValue;
if (!userQuestion && exampleData.length > 0 && exampleChoice) {
// 質問が空欄なら、質問例を使用
Expand All @@ -107,117 +65,16 @@ export function ChatForm({ path, langName, sectionContent, close }: ChatFormProp
return;
}

e.preventDefault();
setIsLoading(true);
setErrorMessage(null); // Clear previous error message

let response: Response;
try {
response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path,
userQuestion,
questionScope,
sectionContent,
replOutputs,
files,
execResults,
}),
});
} catch (e) {
captureException(e);
setErrorMessage("AIへの接続に失敗しました");
setIsLoading(false);
return;
}

if (!response.ok) {
setErrorMessage(`エラーが発生しました (${response.status})`);
setIsLoading(false);
return;
}

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let chatId: string | null = null;
let chatPagePath: string | PagePath = path;
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") {
chatPagePath = event.pagePath;
// revalidateChatは/api/chatの中では呼ばず、別のServerActionとして呼び出す
await revalidateChatAction(event.chatId, event.pagePath);
chatId = event.chatId;
streamingChatContext.startStreaming(event.chatId);
if (event.pagePath === `${path.lang}/${path.page}`) {
document.getElementById(event.sectionId)?.scrollIntoView({
behavior: "smooth",
});
}
await asyncRouterPush(`/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") {
if (chatId) {
await revalidateChatAction(chatId, chatPagePath);
}
streamingChatContext.finishStreaming();
router.refresh();
} else if (event.type === "error") {
if (!navigated) {
setErrorMessage(event.message);
setIsLoading(false);
}
if (chatId) {
await revalidateChatAction(chatId, chatPagePath);
}
streamingChatContext.finishStreaming();
router.refresh();
}
} catch (e) {
captureException(e);
// ignore JSON parse errors
}
}
}
} catch (err) {
captureException(err);
console.error("Stream reading failed:", err);
// ナビゲーション後のエラーはストリーミングを終了してローディングを止める
if (!navigated) {
setErrorMessage(String(err));
setIsLoading(false);
}
streamingChatContext.finishStreaming();
}
})();
await sendChat({
path,
userQuestion,
questionScope,
sectionContent,
onSuccess: () => {
setInputValue("");
close();
},
});
};

return (
Expand Down
Loading
Loading