diff --git a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx index 349f07bb..f38ce7ee 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx @@ -7,9 +7,8 @@ import { useState, FormEvent, useEffect } from "react"; // QuestionExampleParams, // } from "../actions/questionExample"; // import { getLanguageName } from "../pagesList"; -import { DynamicMarkdownSection } from "./pageContent"; import { useEmbedContext } from "@/terminal/embedContext"; -import { PagePath } from "@/lib/docs"; +import { DynamicMarkdownSection, PagePath } from "@/lib/docs"; import { useRouter } from "next/navigation"; import { ChatStreamEvent } from "@/api/chat/route"; import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 2e54858b..f2daec28 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -7,33 +7,18 @@ import { useSidebarMdContext } from "@/sidebar"; import clsx from "clsx"; import { PageTransition } from "./pageTransition"; import { + DynamicMarkdownSection, LanguageEntry, MarkdownSection, PageEntry, PagePath, SectionId, } from "@/lib/docs"; -import { ReplacedRange } from "@/markdown/multiHighlight"; import { Heading } from "@/markdown/heading"; import Link from "next/link"; import { useChatId } from "@/(docs)/chatAreaState"; import { ChatWithMessages } from "@/lib/chatHistory"; -/** - * MarkdownSectionに追加で、動的な情報を持たせる - */ -export interface DynamicMarkdownSection extends MarkdownSection { - /** - * ユーザーが今そのセクションを読んでいるかどうか - */ - inView: boolean; - /** - * チャットの会話を元にAIが書き換えた後の内容 - */ - replacedContent: string; - replacedRange: ReplacedRange[]; -} - interface PageContentProps { splitMdContent: MarkdownSection[]; langEntry: LanguageEntry; diff --git a/app/actions/deleteChat.ts b/app/actions/deleteChat.ts index 32ce131f..65ec9507 100644 --- a/app/actions/deleteChat.ts +++ b/app/actions/deleteChat.ts @@ -1,8 +1,10 @@ "use server"; +import { z } from "zod"; import { deleteChat, initContext } from "@/lib/chatHistory"; export async function deleteChatAction(chatId: string) { + chatId = z.uuid().parse(chatId); const ctx = await initContext(); await deleteChat(chatId, ctx); } diff --git a/app/actions/getRedirectFromChat.ts b/app/actions/getRedirectFromChat.ts index 7e9be601..57e69c8d 100644 --- a/app/actions/getRedirectFromChat.ts +++ b/app/actions/getRedirectFromChat.ts @@ -1,11 +1,14 @@ "use server"; +import { z } from "zod"; import { initContext } from "@/lib/chatHistory"; import { LangId, PageSlug } from "@/lib/docs"; import { chat, section } from "@/schema/chat"; import { and, eq } from "drizzle-orm"; export async function getRedirectFromChat(chatId: string): Promise { + chatId = z.uuid().parse(chatId); + const { drizzle, userId } = await initContext(); if (!userId) { throw new Error("Not authenticated"); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e37708a1..dd0beca0 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -6,18 +6,27 @@ import { CreateChatDiff, initContext, } from "@/lib/chatHistory"; -import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; -import { DynamicMarkdownSection } from "@/(docs)/@docs/[lang]/[pageId]/pageContent"; -import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; +import { + DynamicMarkdownSectionSchema, + getPagesList, + introSectionId, + PagePathSchema, + SectionId, +} from "@/lib/docs"; +import { + ReplCommandSchema, + ReplOutputSchema, +} from "@my-code/runtime/interface"; +import { z } from "zod"; -type ChatParams = { - path: PagePath; - userQuestion: string; - sectionContent: DynamicMarkdownSection[]; - replOutputs: Record; - files: Record; - execResults: Record; -}; +const ChatParamsSchema = z.object({ + path: PagePathSchema, + userQuestion: z.string().min(1), + 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 } @@ -31,9 +40,18 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const params = (await request.json()) as ChatParams; - const { path, userQuestion, sectionContent, replOutputs, files, execResults } = - params; + const parseResult = ChatParamsSchema.safeParse(await request.json()); + if (!parseResult.success) { + return new Response(JSON.stringify(parseResult.error), { status: 400 }); + } + const { + path, + userQuestion, + sectionContent, + replOutputs, + files, + execResults, + } = parseResult.data; const pagesList = await getPagesList(); const langName = pagesList.find((lang) => lang.id === path.lang)?.name; @@ -202,7 +220,7 @@ export async function POST(request: NextRequest) { prompt.join("\n") )) { console.log("Received chunk:", [chunk]); - + fullText += chunk; if (!headerParsed) { @@ -294,9 +312,7 @@ export async function POST(request: NextRequest) { await addMessagesAndDiffs( chatId, path, - [ - { role: "ai", content: cleanMessage }, - ], + [{ role: "ai", content: cleanMessage }], diffRaw, context ); diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 4104a9b0..cbe8a367 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -5,6 +5,7 @@ import yaml from "js-yaml"; import { isCloudflare } from "./detectCloudflare"; import { notFound } from "next/navigation"; import crypto from "node:crypto"; +import { z } from "zod"; /* Branded Types @@ -18,33 +19,61 @@ type Brand = K & { readonly __brand: T }; export type LangId = Brand; export type LangName = Brand; export type PageSlug = Brand; +export type SectionId = Brand; + +export const PagePathSchema = z.object({ + lang: z.string().transform((s) => s as LangId), + page: z.string().transform((s) => s as PageSlug), +}); export interface PagePath { lang: LangId; page: PageSlug; } -export type SectionId = Brand; -export interface MarkdownSection { +export const MarkdownSectionSchema = z.object({ /** * セクションのmdファイル名 */ - file: string; + file: z.string(), /** * frontmatterに書くセクションid * (データベース上の sectionId) */ - id: SectionId; - level: number; - title: string; + id: z.string().transform((s) => s as SectionId), + level: z.number(), + title: z.string(), /** * frontmatterを除く、見出しも含めたもとのmarkdownの内容 */ - rawContent: string; + rawContent: z.string(), /** * rawContentのmd5ハッシュのbase64エンコード */ - md5: string; -} + md5: z.string(), +}); +export type MarkdownSection = z.output; + +export const ReplacedRangeSchema = z.object({ + start: z.number(), + end: z.number(), + id: z.string(), +}); +export type ReplacedRange = z.output; + +export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({ + /** + * ユーザーが今そのセクションを読んでいるかどうか + */ + inView: z.boolean(), + /** + * チャットの会話を元にAIが書き換えた後の内容 + */ + replacedContent: z.string(), + replacedRange: z.array(ReplacedRangeSchema), +}); +export type DynamicMarkdownSection = z.output< + typeof DynamicMarkdownSectionSchema +>; /** * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 diff --git a/app/markdown/markdown.tsx b/app/markdown/markdown.tsx index e55b51c5..2203c965 100644 --- a/app/markdown/markdown.tsx +++ b/app/markdown/markdown.tsx @@ -5,10 +5,10 @@ import remarkCjkFriendly from "remark-cjk-friendly"; import { MultiHighlightTag, remarkMultiHighlight, - ReplacedRange, } from "./multiHighlight"; import { Heading } from "./heading"; import { AutoCodeBlock } from "./codeBlock"; +import { ReplacedRange } from "@/lib/docs"; export function StyledMarkdown(props: { content: string; diff --git a/app/markdown/multiHighlight.tsx b/app/markdown/multiHighlight.tsx index aa5dd471..b118c0eb 100644 --- a/app/markdown/multiHighlight.tsx +++ b/app/markdown/multiHighlight.tsx @@ -6,12 +6,8 @@ import { ExtraProps } from "react-markdown"; import clsx from "clsx"; import { useChatId } from "@/(docs)/chatAreaState"; import Link from "next/link"; +import type { ReplacedRange } from "@/lib/docs"; -export interface ReplacedRange { - start: number; - end: number; - id: string; -} export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = ( replacedRange?: ReplacedRange[] ) => { diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 384074d1..eaf0034f 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs"; +import { DynamicMarkdownSection, LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs"; import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./themeToggle"; import { @@ -15,7 +15,6 @@ import { import clsx from "clsx"; import { LanguageIcon } from "@/terminal/icons"; import { RuntimeLang } from "@my-code/runtime/languages"; -import { DynamicMarkdownSection } from "./(docs)/@docs/[lang]/[pageId]/pageContent"; export interface ISidebarMdContext { loadedPath: PagePath | null; diff --git a/package-lock.json b/package-lock.json index e06b8298..ac4b7edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22162,7 +22162,8 @@ "react": "^19", "react-dom": "^19", "swr": "^2", - "typescript": "^5" + "typescript": "^5", + "zod": "^4.0.17" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 90edd5af..97bc1d5d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -20,7 +20,8 @@ "react": "^19", "react-dom": "^19", "swr": "^2", - "typescript": "^5" + "typescript": "^5", + "zod": "^4.0.17" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index 07fb44e6..0d8b26ff 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -1,4 +1,5 @@ import { MutexInterface } from "async-mutex"; +import { z } from "zod"; /** * 各言語の実行環境のインタフェース @@ -148,28 +149,33 @@ export interface RuntimeInfo { version?: string; } -export type ReplOutputType = - | "stdout" - | "stderr" - | "error" - | "return" - | "trace" - | "system"; -export interface ReplOutput { - type: ReplOutputType; // 出力の種類 - message: string; // 出力メッセージ -} -export interface UpdatedFile { - type: "file"; - filename: string; - content: string; -} +export const ReplOutputTypeSchema = z.enum([ + "stdout", + "stderr", + "error", + "return", + "trace", + "system", +]); +export type ReplOutputType = z.output; +export const ReplOutputSchema = z.object({ + type: ReplOutputTypeSchema, // 出力の種類 + message: z.string(), // 出力メッセージ +}); +export type ReplOutput = z.output; +export const UpdatedFileSchema = z.object({ + type: z.literal("file"), + filename: z.string(), + content: z.string(), +}); +export type UpdatedFile = z.output; -export interface ReplCommand { - command: string; - output: ReplOutput[]; - commandId?: string; // Optional for backward compatibility -} +export const ReplCommandSchema = z.object({ + command: z.string(), + output: z.array(ReplOutputSchema), + commandId: z.string().optional(), // Optional for backward compatibility +}); +export type ReplCommand = z.output; export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果 export const emptyMutex: MutexInterface = {