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
52 changes: 43 additions & 9 deletions app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import { captureException } from "@sentry/nextjs";

interface ChatFormProps {
path: PagePath;
langName: string;
sectionContent: DynamicMarkdownSection[];
close: () => void;
Comment on lines 25 to 29
}

export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
export function ChatForm({ path, langName, sectionContent, close }: ChatFormProps) {
// const [messages, updateChatHistory] = useChatHistory(sectionId);
const [inputValue, setInputValue] = useState("");
const [questionScope, setQuestionScope] = useState<"page" | "language">(
"page"
);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

Expand Down Expand Up @@ -115,6 +119,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
body: JSON.stringify({
path,
userQuestion,
questionScope,
sectionContent,
replOutputs,
files,
Expand All @@ -138,6 +143,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
const decoder = new TextDecoder();
let buffer = "";
let chatId: string | null = null;
let chatPagePath: string | PagePath = path;
let navigated = false;

// ストリームを非同期で読み続ける(ナビゲーション後もバックグラウンドで継続)
Expand All @@ -158,13 +164,16 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
const event = JSON.parse(line) as ChatStreamEvent;

if (event.type === "chat") {
chatPagePath = event.pagePath;
// revalidateChatは/api/chatの中では呼ばず、別のServerActionとして呼び出す
await revalidateChatAction(event.chatId, path);
await revalidateChatAction(event.chatId, event.pagePath);
chatId = event.chatId;
streamingChatContext.startStreaming(event.chatId);
document.getElementById(event.sectionId)?.scrollIntoView({
behavior: "smooth",
});
if (event.pagePath === `${path.lang}/${path.page}`) {
document.getElementById(event.sectionId)?.scrollIntoView({
behavior: "smooth",
});
}
await asyncRouterPush(`/chat/${event.chatId}`, {
scroll: false,
});
Expand All @@ -177,7 +186,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
streamingChatContext.appendChunk(event.text);
} else if (event.type === "done") {
if (chatId) {
await revalidateChatAction(chatId, path);
await revalidateChatAction(chatId, chatPagePath);
}
streamingChatContext.finishStreaming();
router.refresh();
Expand All @@ -187,7 +196,7 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
setIsLoading(false);
}
if (chatId) {
await revalidateChatAction(chatId, path);
await revalidateChatAction(chatId, chatPagePath);
}
streamingChatContext.finishStreaming();
router.refresh();
Expand Down Expand Up @@ -216,7 +225,6 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
className="border border-2 border-secondary shadow-lg rounded-box bg-base-100/60 backdrop-blur-xs"
style={{
width: "100%",
textAlign: "center",
}}
onSubmit={handleSubmit}
>
Expand All @@ -238,9 +246,35 @@ export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
onChange={(e) => setInputValue(e.target.value)}
disabled={isLoading}
></textarea>
<div className="px-3 flex flex-wrap gap-3 text-sm">
<label className="label cursor-pointer gap-2">
<input
type="radio"
className="radio radio-sm radio-secondary"
name="question-scope"
value="page"
checked={questionScope === "page"}
onChange={() => setQuestionScope("page")}
disabled={isLoading}
/>
<span className="label-text">このページの内容について質問</span>
</label>
<label className="label cursor-pointer gap-2">
<input
type="radio"
className="radio radio-sm radio-secondary"
name="question-scope"
value="language"
checked={questionScope === "language"}
onChange={() => setQuestionScope("language")}
disabled={isLoading}
/>
<span className="label-text">{`${langName}全体について質問`}</span>
</label>
</div>
<div
className="m-3"
style={{
margin: "10px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
Expand Down
1 change: 1 addition & 0 deletions app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export function PageContent(props: PageContentProps) {
<div className="fixed bottom-4 right-4 left-4 has-sidebar:left-[calc(var(--container-sidebar)+1rem)] z-40">
<ChatForm
path={path}
langName={props.langEntry.name}
sectionContent={dynamicMdContent}
close={() => setIsFormVisible(false)}
/>
Expand Down
105 changes: 88 additions & 17 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/lib/chatHistory";
import {
DynamicMarkdownSectionSchema,
getMarkdownSections,
getPagesList,
introSectionId,
PagePathSchema,
Expand All @@ -23,14 +24,15 @@ import { captureException } from "@sentry/nextjs";
const ChatParamsSchema = z.object({
path: PagePathSchema,
userQuestion: z.string().min(1),
questionScope: z.enum(["page", "language"]).default("page"),
sectionContent: z.array(DynamicMarkdownSectionSchema),
replOutputs: z.record(z.string(), z.array(ReplCommandSchema)),
files: z.record(z.string(), z.string()),
execResults: z.record(z.string(), z.array(ReplOutputSchema)),
});

export type ChatStreamEvent =
| { type: "chat"; chatId: string; sectionId: string }
| { type: "chat"; chatId: string; sectionId: string; pagePath: string }
| { type: "chunk"; text: string }
| { type: "done" }
| { type: "error"; message: string };
Expand All @@ -48,14 +50,81 @@ export async function POST(request: NextRequest) {
const {
path,
userQuestion,
questionScope,
sectionContent,
replOutputs,
files,
execResults,
} = parseResult.data;

const pagesList = await getPagesList();
const langName = pagesList.find((lang) => lang.id === path.lang)?.name;
const langEntry = pagesList.find((lang) => lang.id === path.lang);
const langName = langEntry?.name ?? path.lang;
let targetPath = path;
let targetSectionContent = sectionContent;

if (questionScope === "language" && langEntry) {
const routePrompt: string[] = [];
routePrompt.push(
`あなたは${langName}言語チュートリアルの質問ルーターです。ユーザーの質問に最も関連するページslugを選んでください。`
);
routePrompt.push(``);
routePrompt.push("# 指示");
routePrompt.push(
"- 1行目にページslugのみを出力してください(引用符や補足説明は不要です)。"
);
routePrompt.push(
"- どのページにも関連しない場合は null と出力してください。"
);
routePrompt.push(``);
routePrompt.push("# 目次");
routePrompt.push(``);
for (const page of langEntry.pages) {
const sections = await getMarkdownSections(path.lang, page.slug);
const sectionTitles = sections
.map((s) => s.title.trim())
.filter((title) => title.length > 0)
.join(" / ");
routePrompt.push(
`- slug: ${page.slug} | 章題: ${page.title} | セクション: ${sectionTitles}`
);
}
Comment on lines +82 to +91
routePrompt.push(``);
routePrompt.push(`# ユーザーの質問`);
routePrompt.push(userQuestion);
routePrompt.push(``);
routePrompt.push(`# 回答`);

let routeResult = "";
for await (const chunk of generateContentStream(
userQuestion,
routePrompt.join("\n")
)) {
routeResult += chunk;
}
const selectedPageSlug = routeResult
.trim()
.split(/\s+/)[0]
?.replace(/^slug:/i, "")
.replace(/^["'`]+|["'`.,:;]+$/g, "")
.trim() as typeof path.page | undefined;
if (selectedPageSlug && langEntry.pages.some((page) => page.slug === selectedPageSlug)) {
targetPath = {
lang: path.lang,
page: selectedPageSlug,
};
const routedSections = await getMarkdownSections(
targetPath.lang,
targetPath.page
);
targetSectionContent = routedSections.map((section) => ({
...section,
inView: false,
replacedContent: section.rawContent,
replacedRange: [],
}));
}
}

const prompt: string[] = [];

Expand All @@ -64,16 +133,17 @@ export async function POST(request: NextRequest) {
`以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。`
);
prompt.push(``);
const sectionTitlesInView = sectionContent
const sectionTitlesInView = targetSectionContent
.filter((s) => s.inView)
.map((s) => s.title)
.join(", ");
prompt.push(
`ユーザーはドキュメント内の ${sectionTitlesInView} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。`
);
prompt.push(
`質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。`
);
.map((s) => s.title);
if (sectionTitlesInView.length > 0) {
prompt.push(
`ユーザーはドキュメント内の ${sectionTitlesInView.join(", ")} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。`
);
prompt.push(
`質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。`
);
}
prompt.push(``);
prompt.push(
`質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。`
Expand All @@ -84,7 +154,7 @@ export async function POST(request: NextRequest) {
prompt.push(``);
prompt.push(`# ドキュメント`);
prompt.push(``);
for (const section of sectionContent) {
for (const section of targetSectionContent) {
prompt.push(`[セクションid: ${section.id}]`);
prompt.push(section.replacedContent.trim());
prompt.push(``);
Expand Down Expand Up @@ -235,9 +305,9 @@ export async function POST(request: NextRequest) {

if (
!targetSectionId ||
!sectionContent.some((s) => s.id === targetSectionId)
!targetSectionContent.some((s) => s.id === targetSectionId)
) {
targetSectionId = introSectionId(path);
targetSectionId = introSectionId(targetPath);
}

if (!title) {
Expand All @@ -260,7 +330,7 @@ export async function POST(request: NextRequest) {

// Create chat record in DB immediately
const newChat = await addChat(
path,
targetPath,
targetSectionId,
title,
[{ role: "user", content: userQuestion }],
Expand All @@ -274,6 +344,7 @@ export async function POST(request: NextRequest) {
type: "chat",
chatId,
sectionId: targetSectionId,
pagePath: `${targetPath.lang}/${targetPath.page}`,
});

// Send any content that came after the header in this chunk
Expand Down Expand Up @@ -313,7 +384,7 @@ export async function POST(request: NextRequest) {
for (const m of contentAfterHeader.matchAll(diffRegex)) {
const search = m[1];
const replace = m[2];
const targetSection = sectionContent.find((s) =>
const targetSection = targetSectionContent.find((s) =>
s.replacedContent.includes(search)
);
diffRaw.push({
Expand All @@ -328,7 +399,7 @@ export async function POST(request: NextRequest) {
// Save messages and diffs to DB
await addMessagesAndDiffs(
chatId,
path,
targetPath,
[{ role: "ai", content: cleanMessage }],
diffRaw,
context
Expand Down
Loading