Skip to content

Commit 64e688d

Browse files
authored
Merge pull request #59 from ut-code/section-in-view
画面内のセクションを取得する機能を追加
2 parents 5605e7f + 66fc13f commit 64e688d

24 files changed

+720
-540
lines changed

app/[docs_id]/chatForm.tsx

Lines changed: 99 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,50 @@
11
"use client";
22

3-
import { useState, FormEvent } from "react";
4-
import clsx from "clsx";
3+
import { useState, FormEvent, useEffect } from "react";
54
import { askAI } from "@/app/actions/chatActions";
6-
import { StyledMarkdown } from "./markdown";
7-
import { useChatHistory, type Message } from "../hooks/useChathistory";
85
import useSWR from "swr";
9-
import { getQuestionExample } from "../actions/questionExample";
6+
import {
7+
getQuestionExample,
8+
QuestionExampleParams,
9+
} from "../actions/questionExample";
1010
import { getLanguageName } from "../pagesList";
11-
import { ReplCommand, ReplOutput } from "../terminal/repl";
11+
import { DynamicMarkdownSection } from "./pageContent";
12+
import { useEmbedContext } from "../terminal/embedContext";
13+
import { ChatMessage, useChatHistoryContext } from "./chatHistory";
1214

1315
interface ChatFormProps {
16+
docs_id: string;
1417
documentContent: string;
15-
sectionId: string;
16-
replOutputs: ReplCommand[];
17-
fileContents: Array<{
18-
name: string;
19-
content: string;
20-
}>;
21-
execResults: Record<string, ReplOutput[]>;
18+
sectionContent: DynamicMarkdownSection[];
19+
close: () => void;
2220
}
2321

2422
export function ChatForm({
23+
docs_id,
2524
documentContent,
26-
sectionId,
27-
replOutputs,
28-
fileContents,
29-
execResults,
25+
sectionContent,
26+
close,
3027
}: ChatFormProps) {
31-
const [messages, updateChatHistory] = useChatHistory(sectionId);
28+
// const [messages, updateChatHistory] = useChatHistory(sectionId);
3229
const [inputValue, setInputValue] = useState("");
3330
const [isLoading, setIsLoading] = useState(false);
34-
const [isFormVisible, setIsFormVisible] = useState(false);
3531

36-
const lang = getLanguageName(sectionId);
32+
const { addChat } = useChatHistoryContext();
33+
34+
const lang = getLanguageName(docs_id);
35+
36+
const { files, replOutputs, execResults } = useEmbedContext();
37+
38+
const documentContentInView = sectionContent
39+
.filter((s) => s.inView)
40+
.map((s) => s.rawContent)
41+
.join("\n\n");
3742
const { data: exampleData, error: exampleError } = useSWR(
3843
// 質問フォームを開いたときだけで良い
39-
isFormVisible ? { lang, documentContent } : null,
44+
{
45+
lang,
46+
documentContent: documentContentInView,
47+
} satisfies QuestionExampleParams,
4048
getQuestionExample,
4149
{
4250
// リクエストは古くても構わないので1回でいい
@@ -51,13 +59,17 @@ export function ChatForm({
5159
// 質問フォームを開くたびにランダムに選び直し、
5260
// exampleData[Math.floor(exampleChoice * exampleData.length)] を採用する
5361
const [exampleChoice, setExampleChoice] = useState<number>(0); // 0〜1
62+
useEffect(() => {
63+
if (exampleChoice === 0) {
64+
setExampleChoice(Math.random());
65+
}
66+
}, [exampleChoice]);
5467

5568
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
5669
e.preventDefault();
5770
setIsLoading(true);
5871

59-
const userMessage: Message = { sender: "user", text: inputValue };
60-
updateChatHistory([userMessage]);
72+
const userMessage: ChatMessage = { sender: "user", text: inputValue };
6173

6274
let userQuestion = inputValue;
6375
if (!userQuestion && exampleData) {
@@ -69,148 +81,89 @@ export function ChatForm({
6981

7082
const result = await askAI({
7183
userQuestion,
72-
documentContent: documentContent,
84+
documentContent,
85+
sectionContent,
7386
replOutputs,
74-
fileContents,
87+
files,
7588
execResults,
7689
});
7790

7891
if (result.error) {
79-
const errorMessage: Message = {
80-
sender: "ai",
92+
const errorMessage: ChatMessage = {
93+
sender: "error",
8194
text: `エラー: ${result.error}`,
82-
isError: true,
8395
};
84-
updateChatHistory([userMessage, errorMessage]);
96+
console.log(result.error);
97+
// TODO: ユーザーに表示
8598
} else {
86-
const aiMessage: Message = { sender: "ai", text: result.response };
87-
updateChatHistory([userMessage, aiMessage]);
99+
const aiMessage: ChatMessage = { sender: "ai", text: result.response };
100+
const chatId = addChat(result.targetSectionId, [userMessage, aiMessage]);
101+
// TODO: chatIdが指す対象の回答にフォーカス
88102
setInputValue("");
103+
close();
89104
}
90105

91106
setIsLoading(false);
92107
};
93108

94-
const handleClearHistory = () => {
95-
updateChatHistory([]);
96-
};
97-
98109
return (
99-
<>
100-
{isFormVisible && (
101-
<form
102-
className="border border-2 border-secondary shadow-md rounded-lg bg-base-100"
110+
<form
111+
className="border border-2 border-secondary shadow-lg rounded-lg bg-base-100"
112+
style={{
113+
width: "100%",
114+
textAlign: "center",
115+
}}
116+
onSubmit={handleSubmit}
117+
>
118+
<div className="input-area">
119+
<textarea
120+
className="textarea textarea-ghost textarea-md rounded-lg"
121+
placeholder={
122+
"質問を入力してください" +
123+
(exampleData
124+
? ` (例:「${exampleData[Math.floor(exampleChoice * exampleData.length)]}」)`
125+
: "")
126+
}
103127
style={{
104128
width: "100%",
105-
textAlign: "center",
106-
boxShadow: "-moz-initial",
129+
height: "110px",
130+
resize: "none",
131+
outlineStyle: "none",
107132
}}
108-
onSubmit={handleSubmit}
109-
>
110-
<div className="input-area">
111-
<textarea
112-
className="textarea textarea-ghost textarea-md rounded-lg"
113-
placeholder={
114-
"質問を入力してください" +
115-
(exampleData
116-
? ` (例:「${exampleData[Math.floor(exampleChoice * exampleData.length)]}」)`
117-
: "")
118-
}
119-
style={{
120-
width: "100%",
121-
height: "110px",
122-
resize: "none",
123-
outlineStyle: "none",
124-
}}
125-
value={inputValue}
126-
onChange={(e) => setInputValue(e.target.value)}
127-
disabled={isLoading}
128-
></textarea>
129-
</div>
130-
<div
131-
className="controls"
132-
style={{
133-
margin: "10px",
134-
display: "flex",
135-
alignItems: "center",
136-
justifyContent: "space-between",
137-
}}
133+
value={inputValue}
134+
onChange={(e) => setInputValue(e.target.value)}
135+
disabled={isLoading}
136+
></textarea>
137+
</div>
138+
<div
139+
className="controls"
140+
style={{
141+
margin: "10px",
142+
display: "flex",
143+
alignItems: "center",
144+
justifyContent: "space-between",
145+
}}
146+
>
147+
<div className="left-icons">
148+
<button
149+
className="btn btn-soft btn-secondary rounded-full"
150+
onClick={close}
151+
type="button"
138152
>
139-
<div className="left-icons">
140-
<button
141-
className="btn btn-soft btn-secondary rounded-full"
142-
onClick={() => setIsFormVisible(false)}
143-
type="button"
144-
>
145-
閉じる
146-
</button>
147-
</div>
148-
<div className="right-controls">
149-
<button
150-
type="submit"
151-
className="btn btn-soft btn-circle btn-accent border-2 border-accent rounded-full"
152-
title="送信"
153-
disabled={isLoading}
154-
>
155-
<span className="icon"></span>
156-
</button>
157-
</div>
158-
</div>
159-
</form>
160-
)}
161-
{!isFormVisible && (
162-
<button
163-
className="btn btn-soft btn-secondary rounded-full"
164-
onClick={() => {
165-
setIsFormVisible(true);
166-
setExampleChoice(Math.random());
167-
}}
168-
>
169-
チャットを開く
170-
</button>
171-
)}
172-
173-
{messages.length > 0 && (
174-
<article className="mt-4">
175-
<div className="flex justify-between items-center mb-2">
176-
<h3 className="text-lg font-semibold">AIとのチャット</h3>
177-
<button
178-
onClick={handleClearHistory}
179-
className="btn btn-ghost btn-sm text-xs"
180-
aria-label="チャット履歴を削除"
181-
>
182-
履歴を削除
183-
</button>
184-
</div>
185-
{messages.map((msg, index) => (
186-
<div
187-
key={index}
188-
className={`chat ${msg.sender === "user" ? "chat-end" : "chat-start"}`}
189-
>
190-
<div
191-
className={clsx(
192-
"chat-bubble",
193-
{ "bg-primary text-primary-content": msg.sender === "user" },
194-
{
195-
"bg-secondary-content dark:bg-neutral text-black dark:text-white":
196-
msg.sender === "ai" && !msg.isError,
197-
},
198-
{ "chat-bubble-error": msg.isError }
199-
)}
200-
style={{ maxWidth: "100%", wordBreak: "break-word" }}
201-
>
202-
<StyledMarkdown content={msg.text} />
203-
</div>
204-
</div>
205-
))}
206-
</article>
207-
)}
208-
209-
{isLoading && (
210-
<div className="mt-2 text-l text-gray-500 animate-pulse">
211-
AIが考え中です…
153+
閉じる
154+
</button>
155+
</div>
156+
<div className="right-controls">
157+
<button
158+
type="submit"
159+
className="btn btn-soft btn-circle btn-accent border-2 border-accent rounded-full"
160+
title="送信"
161+
disabled={isLoading}
162+
>
163+
<span className="icon"></span>
164+
</button>
212165
</div>
213-
)}
214-
</>
166+
</div>
167+
</form>
215168
);
216169
}

app/[docs_id]/chatHistory.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"use client";
2+
3+
import {
4+
createContext,
5+
ReactNode,
6+
useContext,
7+
useEffect,
8+
useState,
9+
} from "react";
10+
11+
export interface ChatMessage {
12+
sender: "user" | "ai" | "error";
13+
text: string;
14+
}
15+
16+
export interface IChatHistoryContext {
17+
chatHistories: Record<string, Record<string, ChatMessage[]>>;
18+
addChat: (sectionId: string, messages: ChatMessage[]) => string;
19+
updateChat: (sectionId: string, chatId: string, message: ChatMessage) => void;
20+
}
21+
const ChatHistoryContext = createContext<IChatHistoryContext | null>(null);
22+
export function useChatHistoryContext() {
23+
const context = useContext(ChatHistoryContext);
24+
if (!context) {
25+
throw new Error(
26+
"useChatHistoryContext must be used within a ChatHistoryProvider"
27+
);
28+
}
29+
return context;
30+
}
31+
32+
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
33+
const [chatHistories, setChatHistories] = useState<
34+
Record<string, Record<string, ChatMessage[]>>
35+
>({});
36+
useEffect(() => {
37+
// Load chat histories from localStorage on mount
38+
const chatHistories: Record<string, Record<string, ChatMessage[]>> = {};
39+
for (let i = 0; i < localStorage.length; i++) {
40+
const key = localStorage.key(i);
41+
if (key && key.startsWith("chat/") && key.split("/").length === 3) {
42+
const savedHistory = localStorage.getItem(key);
43+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
44+
const [_, sectionId, chatId] = key.split("/");
45+
if (savedHistory) {
46+
if (!chatHistories[sectionId]) {
47+
chatHistories[sectionId] = {};
48+
}
49+
chatHistories[sectionId][chatId] = JSON.parse(savedHistory);
50+
}
51+
}
52+
}
53+
setChatHistories(chatHistories);
54+
}, []);
55+
56+
const addChat = (sectionId: string, messages: ChatMessage[]): string => {
57+
const chatId = Date.now().toString();
58+
const newChatHistories = { ...chatHistories };
59+
if (!newChatHistories[sectionId]) {
60+
newChatHistories[sectionId] = {};
61+
}
62+
newChatHistories[sectionId][chatId] = messages;
63+
setChatHistories(newChatHistories);
64+
localStorage.setItem(
65+
`chat/${sectionId}/${chatId}`,
66+
JSON.stringify(messages)
67+
);
68+
return chatId;
69+
};
70+
const updateChat = (
71+
sectionId: string,
72+
chatId: string,
73+
message: ChatMessage
74+
) => {
75+
const newChatHistories = { ...chatHistories };
76+
if (newChatHistories[sectionId] && newChatHistories[sectionId][chatId]) {
77+
newChatHistories[sectionId][chatId] = [
78+
...newChatHistories[sectionId][chatId],
79+
message,
80+
];
81+
setChatHistories(newChatHistories);
82+
localStorage.setItem(
83+
`chat/${sectionId}/${chatId}`,
84+
JSON.stringify(newChatHistories[sectionId][chatId])
85+
);
86+
}
87+
};
88+
89+
return (
90+
<ChatHistoryContext.Provider value={{ chatHistories, addChat, updateChat }}>
91+
{children}
92+
</ChatHistoryContext.Provider>
93+
);
94+
}

0 commit comments

Comments
 (0)