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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

/app/generated/prisma
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,31 @@ https://my-code.utcode.net
npm ci
```

ルートディレクトリに .env.local という名前のファイルを作成し、Gemini APIキーを設定してください
ルートディレクトリに .env.local という名前のファイルを作成し、以下の内容を記述
```dotenv
API_KEY="XXXXXXXX"
API_KEY=GeminiAPIキー
BETTER_AUTH_URL=http://localhost:3000
```

prismaの開発環境を起動
(.env にDATABASE_URLが自動的に追加される)
```bash
npx prisma dev
```
別ターミナルで
```bash
npx prisma db push
```

### 本番環境の場合

上記の環境変数以外に、
* BETTER_AUTH_SECRET に任意の文字列
* DATABASE_URL に本番用のPostgreSQLデータベースURL
* GOOGLE_CLIENT_IDとGOOGLE_CLIENT_SECRETにGoogle OAuthのクライアントIDとシークレット https://www.better-auth.com/docs/authentication/google
* GITHUB_CLIENT_IDとGITHUB_CLIENT_SECRETにGitHub OAuthのクライアントIDとシークレット https://www.better-auth.com/docs/authentication/github


## 開発環境

```bash
Expand Down
12 changes: 5 additions & 7 deletions app/[docs_id]/chatForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { useState, FormEvent, useEffect } from "react";
import { askAI } from "@/app/actions/chatActions";
import useSWR from "swr";
import {
getQuestionExample,
Expand All @@ -10,7 +9,8 @@ import {
import { getLanguageName } from "../pagesList";
import { DynamicMarkdownSection } from "./pageContent";
import { useEmbedContext } from "../terminal/embedContext";
import { ChatMessage, useChatHistoryContext } from "./chatHistory";
import { useChatHistoryContext } from "./chatHistory";
import { askAI } from "@/actions/chatActions";

interface ChatFormProps {
docs_id: string;
Expand Down Expand Up @@ -71,8 +71,6 @@ export function ChatForm({
setIsLoading(true);
setErrorMessage(null); // Clear previous error message

const userMessage: ChatMessage = { sender: "user", text: inputValue };

let userQuestion = inputValue;
if (!userQuestion && exampleData) {
// 質問が空欄なら、質問例を使用
Expand All @@ -83,19 +81,19 @@ export function ChatForm({

const result = await askAI({
userQuestion,
docsId: docs_id,
documentContent,
sectionContent,
replOutputs,
files,
execResults,
});

if (result.error) {
if (result.error !== null) {
setErrorMessage(result.error);
console.log(result.error);
} else {
const aiMessage: ChatMessage = { sender: "ai", text: result.response };
const chatId = addChat(result.targetSectionId, [userMessage, aiMessage]);
addChat(result.chat);
// TODO: chatIdが指す対象の回答にフォーカス
Copy link

Copilot AI Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chatId variable is no longer defined after removing the assignment. If the TODO is still relevant, it should reference result.chat.chatId instead.

Suggested change
// TODO: chatIdが指す対象の回答にフォーカス
// TODO: result.chat.chatIdが指す対象の回答にフォーカス

Copilot uses AI. Check for mistakes.
setInputValue("");
close();
Expand Down
81 changes: 19 additions & 62 deletions app/[docs_id]/chatHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { ChatWithMessages } from "@/lib/chatHistory";
import {
createContext,
ReactNode,
Expand All @@ -8,15 +9,10 @@ import {
useState,
} from "react";

export interface ChatMessage {
sender: "user" | "ai" | "error";
text: string;
}

export interface IChatHistoryContext {
chatHistories: Record<string, Record<string, ChatMessage[]>>;
addChat: (sectionId: string, messages: ChatMessage[]) => string;
updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void;
chatHistories: ChatWithMessages[];
addChat: (chat: ChatWithMessages) => void;
// updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void;
}
const ChatHistoryContext = createContext<IChatHistoryContext | null>(null);
export function useChatHistoryContext() {
Expand All @@ -29,65 +25,26 @@ export function useChatHistoryContext() {
return context;
}

export function ChatHistoryProvider({ children }: { children: ReactNode }) {
const [chatHistories, setChatHistories] = useState<
Record<string, Record<string, ChatMessage[]>>
>({});
export function ChatHistoryProvider({
children,
initialChatHistories,
}: {
children: ReactNode;
initialChatHistories: ChatWithMessages[];
}) {
const [chatHistories, setChatHistories] =
useState<ChatWithMessages[]>(initialChatHistories);
useEffect(() => {
// Load chat histories from localStorage on mount
const chatHistories: Record<string, Record<string, ChatMessage[]>> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("chat/") && key.split("/").length === 3) {
const savedHistory = localStorage.getItem(key);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, sectionId, chatId] = key.split("/");
if (savedHistory) {
if (!chatHistories[sectionId]) {
chatHistories[sectionId] = {};
}
chatHistories[sectionId][chatId] = JSON.parse(savedHistory);
}
}
}
setChatHistories(chatHistories);
}, []);
setChatHistories(initialChatHistories);
}, [initialChatHistories]);

const addChat = (sectionId: string, messages: ChatMessage[]): string => {
const chatId = Date.now().toString();
const newChatHistories = { ...chatHistories };
if (!newChatHistories[sectionId]) {
newChatHistories[sectionId] = {};
}
newChatHistories[sectionId][chatId] = messages;
setChatHistories(newChatHistories);
localStorage.setItem(
`chat/${sectionId}/${chatId}`,
JSON.stringify(messages)
);
return chatId;
};
const updateChat = (
sectionId: string,
chatId: string,
message: ChatMessage
) => {
const newChatHistories = { ...chatHistories };
if (newChatHistories[sectionId] && newChatHistories[sectionId][chatId]) {
newChatHistories[sectionId][chatId] = [
...newChatHistories[sectionId][chatId],
message,
];
setChatHistories(newChatHistories);
localStorage.setItem(
`chat/${sectionId}/${chatId}`,
JSON.stringify(newChatHistories[sectionId][chatId])
);
}
const addChat = (chat: ChatWithMessages) => {
// サーバー側で追加された新しいchatをクライアント側にも反映する
setChatHistories([...chatHistories, chat]);
};

return (
<ChatHistoryContext.Provider value={{ chatHistories, addChat, updateChat }}>
<ChatHistoryContext.Provider value={{ chatHistories, addChat }}>
{children}
</ChatHistoryContext.Provider>
);
Expand Down
5 changes: 4 additions & 1 deletion app/[docs_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MarkdownSection, splitMarkdown } from "./splitMarkdown";
import pyodideLock from "pyodide/pyodide-lock.json";
import { PageContent } from "./pageContent";
import { ChatHistoryProvider } from "./chatHistory";
import { getChat } from "@/lib/chatHistory";

export default async function Page({
params,
Expand Down Expand Up @@ -44,8 +45,10 @@ export default async function Page({

const splitMdContent: MarkdownSection[] = splitMarkdown(mdContent);

const initialChatHistories = await getChat(docs_id);

return (
<ChatHistoryProvider>
<ChatHistoryProvider initialChatHistories={initialChatHistories}>
<PageContent
documentContent={mdContent}
splitMdContent={splitMdContent}
Expand Down
14 changes: 7 additions & 7 deletions app/[docs_id]/pageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export function PageContent(props: PageContentProps) {
</div>
<div key={`${index}-chat`}>
{/* 右側に表示するチャット履歴欄 */}
{Object.entries(chatHistories[section.sectionId] ?? {}).map(
([chatId, messages]) => (
{chatHistories.filter((c) => c.sectionId === section.sectionId).map(
({chatId, messages}) => (
<div
key={chatId}
className="max-w-xs mb-2 p-2 text-sm border border-base-content/10 rounded-sm shadow-sm bg-base-100"
Expand All @@ -104,20 +104,20 @@ export function PageContent(props: PageContentProps) {
{messages.map((msg, index) => (
<div
key={index}
className={`chat ${msg.sender === "user" ? "chat-end" : "chat-start"}`}
className={`chat ${msg.role === "user" ? "chat-end" : "chat-start"}`}
>
<div
className={clsx(
"chat-bubble p-1!",
msg.sender === "user" &&
msg.role === "user" &&
"bg-primary text-primary-content",
msg.sender === "ai" &&
msg.role === "ai" &&
"bg-secondary-content dark:bg-neutral text-black dark:text-white",
msg.sender === "error" && "chat-bubble-error"
msg.role === "error" && "chat-bubble-error"
)}
style={{ maxWidth: "100%", wordBreak: "break-word" }}
>
<StyledMarkdown content={msg.text} />
<StyledMarkdown content={msg.content} />
</div>
</div>
))}
Expand Down
136 changes: 136 additions & 0 deletions app/accountMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { authClient } from "@/lib/auth-client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";

export function AutoAnonymousLogin() {
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session) {
authClient.signIn.anonymous();
}
}, [isPending, session]);

return null;
}

export function AccountMenu() {
const { data: session, isPending } = authClient.useSession();
const pathname = usePathname();

const signout = () => {
if (
window.confirm(
"ログアウトしますか?\nチャット履歴はこの端末上で見られなくなりますが、再度ログインすることでアクセスできます。"
)
) {
authClient.signOut({
fetchOptions: {
onSuccess: () => window.location.reload(),
},
});
}
};
const signoutFromAnonymous = () => {
if (window.confirm("チャット履歴は削除され、アクセスできなくなります。")) {
authClient.signOut({
fetchOptions: {
onSuccess: () => window.location.reload(),
},
});
}
};

if (isPending) {
return <div className="w-10 h-10 skeleton rounded-full"></div>;
}

if (session && !session.user.isAnonymous) {
return (
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
<div className="w-8 h-8 rounded-full">
<img
src={
session.user?.image ??
`https://avatar.vercel.sh/${session.user?.name}`
}
alt="user avatar"
/>
</div>
</label>
<ul
tabIndex={0}
className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52"
>
<li>
<a onClick={signout}>ログアウト</a>
</li>
</ul>
</div>
);
}

return (
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
<div className="w-8 h-8 rounded-full">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-full h-full"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</div>
</label>
<ul className="z-1 shadow-md dropdown-content bg-base-100 rounded-box menu w-64">
<li className="menu-title">
ログインすると、チャット履歴を保存し別のデバイスからもアクセスできるようになります。
</li>
<li>
<button
onClick={() =>
authClient.signIn.social({
provider: "github",
callbackURL: pathname,
})
}
>
GitHub でログイン
</button>
</li>
<li>
<button
onClick={() =>
authClient.signIn.social({
provider: "google",
callbackURL: pathname,
})
}
>
Google でログイン
</button>
</li>
{session?.user && (
<>
<div className="divider my-0" />
<li>
<button onClick={signoutFromAnonymous}>
この端末上のデータを削除
</button>
</li>
</>
)}
</ul>
</div>
);
}
Loading