Skip to content

Commit 835e26a

Browse files
authored
Merge branch 'main' into storage
2 parents 7a9c707 + 878ed18 commit 835e26a

25 files changed

+4008
-2629
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,13 @@ npm run lint
8383

8484
- [Next.js](https://nextjs.org/docs)
8585
- 検索する際は「App Router」を含めることで古い記事に惑わされることが少なくなります。
86+
- [OpenNext](https://opennext.js.org/cloudflare)
8687
- [DaisyUI](https://daisyui.com/docs/use/) / [Tailwind CSS](https://tailwindcss.com/docs)
8788
- buttonやinputやメニューなどの基本的なコンポーネントのデザインはDaisyUIにあるものを使うと楽です
8889
- 細かくスタイルを調整したい場合はTailwind CSSを使います (CSS直接指定(`style={{...}}`)よりもちょっと楽に書ける)
8990
- よくわからなかったらstyle直接指定でも良い
91+
- [SWR](https://swr.vercel.app/ja)
9092
- [react-markdown](https://www.npmjs.com/package/react-markdown)
91-
- オプションがいろいろあり、今はほぼデフォルト設定で突っ込んでいるがあとでなんとかする
92-
- [OpenNext](https://opennext.js.org/cloudflare)
93+
- REPL・実行結果表示: [xterm.js](https://xtermjs.org/)
94+
- コードエディター: [react-ace](https://github.com/securingsincity/react-ace)
95+
- それ以外のコードブロック: [react-syntax-highlighter](https://github.com/react-syntax-highlighter/react-syntax-highlighter)

app/[docs_id]/chatForm.tsx

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import clsx from "clsx";
55
import { askAI } from "@/app/actions/chatActions";
66
import { StyledMarkdown } from "./markdown";
77
import { useChatHistory, type Message } from "../hooks/useChathistory";
8+
import useSWR from "swr";
9+
import { getQuestionExample } from "../actions/questionExample";
10+
import { getLanguageName } from "../pagesList";
811

912
interface ChatFormProps {
1013
documentContent: string;
@@ -13,7 +16,6 @@ interface ChatFormProps {
1316

1417
export function ChatForm({ documentContent, sectionId }: ChatFormProps) {
1518
const [messages, updateChatHistory] = useChatHistory(sectionId);
16-
1719
const [inputValue, setInputValue] = useState("");
1820
const [isLoading, setIsLoading] = useState(false);
1921
const [isFormVisible, setIsFormVisible] = useState(false);
@@ -23,15 +25,41 @@ export function ChatForm({ documentContent, sectionId }: ChatFormProps) {
2325
setIsMounted(true);
2426
}, []);
2527

28+
const lang = getLanguageName(docs_id);
29+
const { data: exampleData, error: exampleError } = useSWR(
30+
// 質問フォームを開いたときだけで良い
31+
isFormVisible ? { lang, documentContent } : null,
32+
getQuestionExample,
33+
{
34+
// リクエストは古くても構わないので1回でいい
35+
revalidateIfStale: false,
36+
revalidateOnFocus: false,
37+
revalidateOnReconnect: false,
38+
}
39+
);
40+
if (exampleError) {
41+
console.error("Error getting question example:", exampleError);
42+
}
43+
// 質問フォームを開くたびにランダムに選び直し、
44+
// exampleData[Math.floor(exampleChoice * exampleData.length)] を採用する
45+
const [exampleChoice, setExampleChoice] = useState<number>(0); // 0〜1
46+
2647
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
2748
e.preventDefault();
2849
setIsLoading(true);
2950

3051
const userMessage: Message = { sender: "user", text: inputValue };
3152
updateChatHistory([userMessage]);
3253

54+
let userQuestion = inputValue;
55+
if(!userQuestion && exampleData){
56+
// 質問が空欄なら、質問例を使用
57+
userQuestion = exampleData[Math.floor(exampleChoice * exampleData.length)];
58+
setInputValue(userQuestion);
59+
}
60+
3361
const result = await askAI({
34-
userQuestion: inputValue,
62+
userQuestion,
3563
documentContent: documentContent,
3664
});
3765

@@ -54,22 +82,28 @@ export function ChatForm({ documentContent, sectionId }: ChatFormProps) {
5482
return (
5583
<>
5684
{isFormVisible && (
57-
<form className="border border-2 border-secondary shadow-xl p-6 rounded-lg bg-base-100" style={{width:"100%", textAlign:"center", boxShadow:"-moz-initial"}} onSubmit={handleSubmit}>
58-
<h2 className="text-xl font-bold mb-4 text-left relative -top-2 font-mono h-2">
59-
AIへ質問
60-
</h2>
61-
<div className="input-area" style={{height:"80px"}}>
85+
<form className="border border-2 border-secondary shadow-md rounded-lg bg-base-100" style={{width:"100%", textAlign:"center", boxShadow:"-moz-initial"}} onSubmit={handleSubmit}>
86+
<div className="input-area">
6287
<textarea
63-
className="textarea textarea-white textarea-md"
64-
placeholder="質問を入力してください"
65-
style={{width: "100%", height: "110px", resize: "none"}}
88+
className="textarea textarea-ghost textarea-md rounded-lg"
89+
placeholder={
90+
"質問を入力してください" +
91+
(exampleData
92+
? ` (例:「${exampleData[Math.floor(exampleChoice * exampleData.length)]}」)`
93+
: "")
94+
}
95+
style={{
96+
width: "100%",
97+
height: "110px",
98+
resize: "none",
99+
outlineStyle: "none",
100+
}}
66101
value={inputValue}
67102
onChange={(e) => setInputValue(e.target.value)}
68103
disabled={isLoading}
69104
></textarea>
70105
</div>
71-
<br />
72-
<div className="controls" style={{position:"relative", top:"22px", display:"flex", alignItems:"center", justifyContent:"space-between"}}>
106+
<div className="controls" style={{margin:"10px", display:"flex", alignItems:"center", justifyContent:"space-between"}}>
73107
<div className="left-icons">
74108
<button
75109
className="btn btn-soft btn-secondary rounded-full"
@@ -96,7 +130,10 @@ export function ChatForm({ documentContent, sectionId }: ChatFormProps) {
96130
{!isFormVisible && (
97131
<button
98132
className="btn btn-soft btn-secondary rounded-full"
99-
onClick={() => setIsFormVisible(true)}
133+
onClick={() => {
134+
setIsFormVisible(true);
135+
setExampleChoice(Math.random());
136+
}}
100137
>
101138
チャットを開く
102139
</button>

app/[docs_id]/markdown.tsx

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import Markdown, { Components } from "react-markdown";
22
import remarkGfm from "remark-gfm";
3-
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3+
import SyntaxHighlighter from "react-syntax-highlighter";
44
import { PythonEmbeddedTerminal } from "../terminal/python/embedded";
55
import { Heading } from "./section";
6-
import { EditorComponent } from "../terminal/editor";
7-
import { ExecFile } from "../terminal/exec";
6+
import { type AceLang, EditorComponent } from "../terminal/editor";
7+
import { ExecFile, ExecLang } from "../terminal/exec";
8+
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/hljs";
89

910
export function StyledMarkdown({ content }: { content: string }) {
1011
return (
@@ -34,6 +35,11 @@ const components: Components = {
3435
strong: ({ node, ...props }) => (
3536
<strong className="text-primary" {...props} />
3637
),
38+
table: ({ node, ...props }) => (
39+
<div className="w-max max-w-full overflow-x-auto mx-auto my-2 rounded-lg border border-base-content/5 shadow-sm">
40+
<table className="table w-max" {...props} />
41+
</div>
42+
),
3743
hr: ({ node, ...props }) => <hr className="border-primary my-4" {...props} />,
3844
pre: ({ node, ...props }) => props.children,
3945
code: ({ node, className, ref, style, ...props }) => {
@@ -52,21 +58,59 @@ const components: Components = {
5258
hello, world!
5359
---------------------------
5460
*/
55-
return (
56-
<div className="border border-primary border-2 shadow-md m-2 rounded-lg">
57-
<ExecFile
58-
language={match[1]}
59-
filename={match[3]}
60-
content={String(props.children || "").replace(/\n$/, "")}
61-
/>
62-
</div>
63-
);
61+
let execLang: ExecLang | undefined = undefined;
62+
switch (match[1]) {
63+
case "python":
64+
execLang = "python";
65+
break;
66+
case "cpp":
67+
case "c++":
68+
execLang = "cpp";
69+
break;
70+
default:
71+
console.warn(`Unsupported language for exec: ${match[1]}`);
72+
break;
73+
}
74+
if (execLang) {
75+
return (
76+
<div className="border border-primary border-2 shadow-md m-2 rounded-lg">
77+
<ExecFile
78+
language={execLang}
79+
filenames={match[3].split(",")}
80+
content={String(props.children || "").replace(/\n$/, "")}
81+
/>
82+
</div>
83+
);
84+
}
6485
} else if (match[3]) {
6586
// ファイル名指定がある場合、ファイルエディター
87+
let aceLang: AceLang | undefined = undefined;
88+
switch (match[1]) {
89+
case "python":
90+
aceLang = "python";
91+
break;
92+
case "cpp":
93+
case "c++":
94+
aceLang = "c_cpp";
95+
break;
96+
case "json":
97+
aceLang = "json";
98+
break;
99+
case "csv":
100+
aceLang = "csv";
101+
break;
102+
case "text":
103+
case "txt":
104+
aceLang = "text";
105+
break;
106+
default:
107+
console.warn(`Unsupported language for editor: ${match[1]}`);
108+
break;
109+
}
66110
return (
67111
<div className="border border-primary border-2 shadow-md m-2 rounded-lg">
68112
<EditorComponent
69-
language={match[1]}
113+
language={aceLang}
70114
tabSize={4}
71115
filename={match[3]}
72116
readonly={match[2] === "-readonly"}
@@ -95,8 +139,8 @@ const components: Components = {
95139
<SyntaxHighlighter
96140
language={match[1]}
97141
PreTag="div"
98-
className="border border-base-300 mx-2 my-2 rounded-lg text-sm! m-2! p-4!"
99-
// style={todo dark theme?}
142+
className="border border-base-content/50 mx-2 my-2 rounded-lg text-sm p-4!"
143+
style={tomorrow} // todo dark theme (editor.tsx で指定したのと同じテーマを選ぶようにすること)
100144
{...props}
101145
>
102146
{String(props.children || "").replace(/\n$/, "")}
@@ -107,8 +151,8 @@ const components: Components = {
107151
return (
108152
<SyntaxHighlighter
109153
PreTag="div"
110-
className="border border-base-300 mx-2 my-2 rounded-lg text-sm! m-2! p-4!"
111-
// style={todo dark theme?}
154+
className="border border-base-content/50 mx-2 my-2 rounded-lg text-sm p-4!"
155+
style={tomorrow} // todo dark theme
112156
{...props}
113157
>
114158
{String(props.children || "").replace(/\n$/, "")}

app/[docs_id]/page.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
44
import { join } from "node:path";
55
import { MarkdownSection, splitMarkdown } from "./splitMarkdown";
66
import { Section } from "./section";
7-
import * as pyodideLock from "pyodide/pyodide-lock.json";
7+
import pyodideLock from "pyodide/pyodide-lock.json";
88

99
export default async function Page({
1010
params,
@@ -22,9 +22,14 @@ export default async function Page({
2222
);
2323
} else {
2424
const cfAssets = getCloudflareContext().env.ASSETS;
25-
mdContent = await cfAssets!
26-
.fetch(`https://assets.local/docs/${docs_id}.md`)
27-
.then((res) => res.text());
25+
const mdRes = await cfAssets!.fetch(
26+
`https://assets.local/docs/${docs_id}.md`
27+
);
28+
if (mdRes.ok) {
29+
mdContent = await mdRes.text();
30+
} else {
31+
notFound();
32+
}
2833
}
2934
} catch (error) {
3035
console.error(error);

app/[docs_id]/section.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useFile } from "../terminal/file";
1515

1616
// セクション内に埋め込まれているターミナルとファイルエディターの内容をSection側から取得できるよう、
1717
// Contextに保存する
18+
// TODO: C++では複数ファイルを実行する場合がありうるが、ここではfilenameを1つしか受け付けない想定になっている
1819
interface ISectionCodeContext {
1920
addReplOutput: (command: string, output: ReplOutput[]) => void;
2021
addFile: (filename: string) => void;

app/[docs_id]/splitMarkdown.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"use server";
2-
31
import { unified } from "unified";
42
import remarkParse from "remark-parse";
53
import remarkGfm from "remark-gfm";
@@ -13,9 +11,9 @@ export interface MarkdownSection {
1311
* Markdownコンテンツを見出しごとに分割し、
1412
* 見出しのレベルとタイトル、内容を含むオブジェクトの配列を返す。
1513
*/
16-
export async function splitMarkdown(
14+
export function splitMarkdown(
1715
content: string
18-
): Promise<MarkdownSection[]> {
16+
): MarkdownSection[] {
1917
const tree = unified().use(remarkParse).use(remarkGfm).parse(content);
2018
// console.log(tree.children.map(({ type, position }) => ({ type, position: JSON.stringify(position) })));
2119
const headingNodes = tree.children.filter((node) => node.type === "heading");

app/actions/questionExample.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use server";
2+
3+
import { GoogleGenerativeAI } from "@google/generative-ai";
4+
import { z } from "zod";
5+
6+
const QuestionExampleSchema = z.object({
7+
lang: z.string().min(1),
8+
documentContent: z.string().min(1),
9+
});
10+
11+
const genAI = new GoogleGenerativeAI(process.env.API_KEY!);
12+
13+
type QuestionExampleParams = z.input<typeof QuestionExampleSchema>;
14+
15+
export async function getQuestionExample(
16+
params: QuestionExampleParams
17+
): Promise<string[]> {
18+
// 質問の例を複数AIに考えさせる。
19+
// stringで複数返して、ChatForm側でその中から1つランダムに選ぶ
20+
// 呼び出し側がSWRで、エラー処理してくれるので、全部throwでいい
21+
// TODO: 同じドキュメントに対して2回以上生成する意味がないので、キャッシュしたいですね
22+
23+
const parseResult = QuestionExampleSchema.safeParse(params);
24+
25+
if (!parseResult.success) {
26+
throw new Error(parseResult.error.issues.map((e) => e.message).join(", "));
27+
}
28+
29+
const { lang, documentContent } = parseResult.data;
30+
31+
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
32+
const prompt = `
33+
以下の${lang}チュートリアルのドキュメントに対して、想定される初心者のユーザーからの質問の例を箇条書きで複数挙げてください。
34+
強調などはせずテキストだけで1行ごとに1つ出力してください。
35+
36+
# ドキュメント
37+
${documentContent}
38+
`;
39+
const result = await model.generateContent(prompt);
40+
const response = result.response;
41+
const text = response.text();
42+
return text.trim().split("\n");
43+
}

app/globals.css

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
@import "tailwindcss";
22
@plugin "daisyui";
33

4-
/* inconsolata-latin-wght-normal */
5-
@font-face {
6-
font-family: "Inconsolata Variable";
7-
font-style: normal;
8-
font-display: swap;
9-
font-weight: 200 900;
10-
src: url(https://cdn.jsdelivr.net/fontsource/fonts/inconsolata:vf@latest/latin-wght-normal.woff2)
11-
format("woff2-variations");
12-
unicode-range:
13-
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
14-
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
15-
U+2215, U+FEFF, U+FFFD;
16-
}
17-
4+
/* CDNからダウンロードするURLを指定したらなんかエラー出るので、npmでインストールしてlayout.tsxでimportすることにした */
185
@theme {
19-
--font-mono: "Inconsolata Variable", monospace;
6+
--font-mono: "Inconsolata Variable", "Noto Sans JP Variable", monospace;
207
}

app/layout.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { Metadata } from "next";
2+
import "@fontsource-variable/noto-sans-jp";
3+
import "@fontsource-variable/inconsolata";
24
import "./globals.css";
35
import { Navbar } from "./navbar";
46
import { Sidebar } from "./sidebar";
57
import { ReactNode } from "react";
68
import { PyodideProvider } from "./terminal/python/pyodide";
79
import { FileProvider } from "./terminal/file";
10+
import { WandboxProvider } from "./terminal/wandbox/wandbox";
811

912
export const metadata: Metadata = {
1013
title: "Create Next App",
@@ -21,9 +24,11 @@ export default function RootLayout({
2124
<input id="drawer-toggle" type="checkbox" className="drawer-toggle" />
2225
<div className="drawer-content flex flex-col">
2326
<Navbar />
24-
<FileProvider>
25-
<PyodideProvider>{children}</PyodideProvider>
26-
</FileProvider>
27+
<FileProvider>
28+
<PyodideProvider>
29+
<WandboxProvider>{children}</WandboxProvider>
30+
</PyodideProvider>
31+
</FileProvider>
2732
</div>
2833
<div className="drawer-side shadow-md">
2934
<label

0 commit comments

Comments
 (0)