Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b3fd3c9
チャットの表示をparallel routesで実装
na-trium-144 Mar 16, 2026
6ff62d9
lib/chatHidtoryからactionとcacheを分離、chatページで実際のチャットを取得
na-trium-144 Mar 17, 2026
739d95a
チャットサイドバーの表示を改善
na-trium-144 Mar 17, 2026
0aa1904
diffの表示を追加
na-trium-144 Mar 17, 2026
84e4980
チャットタイトルの実装、削除ボタン追加など
na-trium-144 Mar 17, 2026
b4f8a52
プロンプト調整
na-trium-144 Mar 17, 2026
4907897
AIの回答時にそのチャットのページを開く
na-trium-144 Mar 17, 2026
398bb07
チャット削除を実装
na-trium-144 Mar 17, 2026
842caa5
chatHistoryをcontextで管理するのをやめる & 削除時のcacheクリア
na-trium-144 Mar 17, 2026
0792753
チャットにloadingを追加、チャットを閉じた時と初期ロードのリダイレクト追加
na-trium-144 Mar 17, 2026
6e6fe7e
チャットリストの見た目を改善
na-trium-144 Mar 17, 2026
de28263
レイアウト改善
na-trium-144 Mar 17, 2026
41d8512
チャットをキャッシュ
na-trium-144 Mar 17, 2026
419f5c7
セクションid空は困る
na-trium-144 Mar 17, 2026
44a177c
エラー修正
na-trium-144 Mar 17, 2026
2d4f7ae
空コンポーネントをnullに変更
na-trium-144 Mar 17, 2026
9846e7e
cache.putを忘れていた
na-trium-144 Mar 17, 2026
bc63846
non-null assertionを削除
na-trium-144 Mar 17, 2026
6d37d66
未使用のaction
na-trium-144 Mar 17, 2026
9c25d19
リダイレクトのエラーハンドリング
na-trium-144 Mar 17, 2026
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
208 changes: 208 additions & 0 deletions app/(docs)/@chat/chat/[chatId]/chatArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"use client";

import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
import { deleteChatAction } from "@/actions/deleteChat";
import { ChatWithMessages } from "@/lib/chatHistory";
import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
import { Heading } from "@/markdown/heading";
import { StyledMarkdown } from "@/markdown/markdown";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ReactNode } from "react";

export function ChatAreaContainer(props: { chatId: string; children: ReactNode }) {
return (
<aside
className={clsx(
// モバイルでは全画面表示する
"fixed inset-0 pt-20 bg-base-100",
// PCではスクロールで動かない右サイドバー
"has-chat-1:sticky has-chat-1:top-16 has-sidebar:top-0 has-chat-1:pt-4",
"has-chat-1:basis-2/5 has-chat-1:max-w-chat-area has-chat-1:h-[calc(100vh-4rem)] has-sidebar:h-screen",
"has-chat-1:shadow-md has-chat-1:bg-base-200",
// navbar(z-40)よりは下、ChatListForSectionのdropdown(デフォルトでz-999だがz-30に変えている)よりも上
"z-35",
"p-4",
"flex flex-col",
"overflow-y-auto"
)}
>
<ChatAreaStateUpdater chatId={props.chatId} />
<div className="flex flex-row items-center">
<span className="flex-1 text-base font-bold opacity-40">
AIへの質問
</span>
<Link className="btn btn-ghost" href="/chat" scroll={false}>
<svg
className="w-8 h-8 -scale-x-100"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 17L13 12L18 7M11 17L6 12L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-lg">閉じる</span>
</Link>
</div>
{props.children}
</aside>
);
}

interface Props {
chatId: string;
chatData: ChatWithMessages;
targetLang: LanguageEntry | undefined;
targetPage: PageEntry | undefined;
targetSection: MarkdownSection | undefined;
}
export function ChatAreaContent(props: Props) {
const { chatId, chatData, targetLang, targetPage, targetSection } = props;

const messagesAndDiffs = [
...chatData.messages.map((msg) => ({ type: "message" as const, ...msg })),
...chatData.diff.map((diff) => ({ type: "diff" as const, ...diff })),
];
messagesAndDiffs.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);

const router = useRouter();

return (
<>
<Heading level={2} className="mt-2!">
{chatData.title}
</Heading>
<div className="flex-none breadcrumbs text-sm">
<ul className="flex-wrap">
<li>
<Link href={`/${targetLang?.id}/${targetLang?.pages[0].slug}`}>
{targetLang?.name}
</Link>
</li>
<li>
<Link href={`/${chatData.section.pagePath}`}>
{targetPage?.index}. {targetPage?.name}
</Link>
</li>
<li>
<Link href={`/${chatData.section.pagePath}#${chatData.sectionId}`}>
{targetSection?.title}
</Link>
</li>
</ul>
</div>
<div className="flex flex-wrap items-center">
<div className="flex-1 text-sm opacity-40" suppressHydrationWarning>
{chatData.createdAt.toLocaleString()}
</div>
<button
className="btn btn-error btn-soft btn-sm"
onClick={async () => {
if (confirm("このチャットを削除してもよろしいですか?")) {
await deleteChatAction(chatId);
router.push("/chat", { scroll: false });
router.refresh();
}
}}
>
{/*<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->*/}
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 11V17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 11V17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 7H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
削除
</button>
</div>
<div className="divider" />
{messagesAndDiffs.map((msg, index) =>
msg.type === "message" ? (
msg.role === "user" ? (
<div key={index} className="chat chat-end">
<div
className="chat-bubble p-0.5! bg-secondary/30"
style={{ maxWidth: "100%", wordBreak: "break-word" }}
>
<StyledMarkdown content={msg.content} />
</div>
</div>
) : msg.role === "ai" ? (
<div key={index} className="">
<StyledMarkdown content={msg.content} />
</div>
) : (
<div key={index} className="text-error">
{msg.content}
</div>
)
) : (
<div
key={index}
className={clsx(
"bg-base-300 rounded-lg border border-2 border-secondary/50"
)}
>
{/* pb-0だとmargin collapsingが起きて変な隙間が空く */}
<del
className={clsx(
"block p-2 pb-[1px] bg-error/10",
"line-through decoration-[color-mix(in_oklab,var(--color-error)_70%,currentColor)]"
)}
>
<StyledMarkdown content={msg.search} />
</del>
<ins className="block no-underline p-2 pt-[1px] bg-success/10">
<StyledMarkdown content={msg.replace} />
</ins>
</div>
)
)}
</>
);
}
16 changes: 16 additions & 0 deletions app/(docs)/@chat/chat/[chatId]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ChatAreaContainer } from "./chatArea";

export default function Loading() {
return (
<ChatAreaContainer chatId={"loading"}>
<div className="skeleton h-7 w-full mt-2 mb-3">{/* heading2 */}</div>
<div className="skeleton h-5 w-2/4 my-2">{/* breadcrumbs */}</div>
<div className="skeleton h-5 w-35 my-1.5">{/* date */}</div>
<div className="divider" />
<div className="skeleton h-15 ml-auto w-2/3 my-1">{/* chat */}</div>
<div className="skeleton h-40 w-full my-2">{/* <p> */}</div>
<div className="skeleton h-15 ml-auto w-2/3 my-1">{/* chat */}</div>
<div className="skeleton h-40 w-full my-2">{/* <p> */}</div>
</ChatAreaContainer>
);
}
78 changes: 78 additions & 0 deletions app/(docs)/@chat/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
cacheKeyForChat,
ChatWithMessages,
getChatOne,
initContext,
} from "@/lib/chatHistory";
import { getMarkdownSections, getPagesList } from "@/lib/docs";
import { ChatAreaContainer, ChatAreaContent } from "./chatArea";
import { unstable_cacheLife, unstable_cacheTag } from "next/cache";
import { isCloudflare } from "@/lib/detectCloudflare";

export default async function ChatPage({
params,
}: {
params: Promise<{ chatId: string }>;
}) {
const { chatId } = await params;

const context = await initContext();
const chatData = await getChatOneFromCache(chatId, context.userId);

if (!chatData) {
// notFound(); だとページ全体が404になってしまう
return (
<ChatAreaContainer chatId={chatId}>
<p>指定されたチャットのデータが見つかりません。</p>
</ChatAreaContainer>
);
}

const pagesList = await getPagesList();
const targetLang = pagesList.find(
(lang) => lang.id === chatData.section.pagePath.split("/")[0]
);
const targetPage = targetLang?.pages.find(
(page) => page.slug === chatData.section.pagePath.split("/")[1]
);
const sections =
targetLang && targetPage
? await getMarkdownSections(targetLang.id, targetPage.slug)
: [];
const targetSection = sections.find((sec) => sec.id === chatData.sectionId);

return (
<ChatAreaContainer chatId={chatId}>
<ChatAreaContent
chatId={chatId}
chatData={chatData}
targetLang={targetLang}
targetPage={targetPage}
targetSection={targetSection}
/>
</ChatAreaContainer>
);
}

async function getChatOneFromCache(chatId: string, userId?: string) {
"use cache";
unstable_cacheLife("days");
unstable_cacheTag(cacheKeyForChat(chatId));

if (!userId) {
return null;
}

if (isCloudflare()) {
const cache = await caches.open("chatHistory");
const cachedResponse = await cache.match(cacheKeyForChat(chatId));
if (cachedResponse) {
const data = (await cachedResponse.json()) as ChatWithMessages;
return data;
}
}

const context = await initContext({ userId });
const chatData = await getChatOne(chatId, context);
return chatData;
}
7 changes: 7 additions & 0 deletions app/(docs)/@chat/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ChatAreaStateUpdater } from "../../chatAreaState";

// /chat にアクセスしたときチャットを閉じる

export default function EmptyPage() {
return <ChatAreaStateUpdater chatId={null} />;
}
5 changes: 5 additions & 0 deletions app/(docs)/@chat/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ChatAreaStateUpdater } from "../chatAreaState";

export default function EmptyPage() {
return <ChatAreaStateUpdater chatId={null} />;
}
31 changes: 31 additions & 0 deletions app/(docs)/@chat/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client"; // Error boundaries must be Client Components

import clsx from "clsx";
import { ChatAreaContainer } from "./chat/[chatId]/chatArea";

export default function Error({
error,
// reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<ChatAreaContainer chatId={"error"}>
<p>ページの読み込み中にエラーが発生しました。</p>
<pre
className={clsx(
"border-2 border-current/20 mt-4 rounded-box p-4! bg-base-300! text-base-content!",
"max-w-full whitespace-pre-wrap"
)}
>
{error.message}
</pre>
{error.digest && (
<p className="mt-2 text-sm text-base-content/50">
Digest: {error.digest}
</p>
)}
</ChatAreaContainer>
);
}
19 changes: 19 additions & 0 deletions app/(docs)/@docs/[lang]/[pageId]/autoRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { PagePath } from "@/lib/docs";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";

export function DocsAutoRedirect(props: { path: PagePath }) {
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (pathname === `/chat`) {
router.replace(`/${props.path.lang}/${props.path.page}`, {
scroll: false,
});
}
}, [pathname, router, props.path.lang, props.path.page]);

return null;
}
Loading
Loading