diff --git a/.vscode/settings.json b/.vscode/settings.json index 96e2675..580dbfd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,5 +31,6 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "prisma.fileWatcher": true + "prisma.fileWatcher": true, + "prisma.pinToPrisma6": false } diff --git a/backend/package.json b/backend/package.json index 82bd1ee..53929a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,7 +20,7 @@ "@hono/swagger-ui": "^0.5.2", "@hono/zod-openapi": "^1.1.3", "@next/env": "^15.5.5", - "@prisma/client": "^6.19.0", + "@prisma/client": "^7.0.0", "@prisma/extension-accelerate": "^2.0.2", "@supabase/supabase-js": "^2.75.0", "agora-token": "^2.0.5", @@ -35,7 +35,7 @@ "@cloudflare/workers-types": "^4.20241127.0", "@types/node": "^24.7.1", "@types/ws": "^8.18.1", - "prisma": "^6.17.0", + "prisma": "^7.0.0", "tsx": "^4.20.6", "wrangler": "^4.46.0" } diff --git a/backend/src/config/avatar-personas.json b/backend/src/config/avatar-personas.json index 33c2f1f..69d720e 100644 --- a/backend/src/config/avatar-personas.json +++ b/backend/src/config/avatar-personas.json @@ -1,44 +1,44 @@ [ - { - "id": "maki", - "name": "まき", - "persona": "明るくて聞き上手。相手の話題を引き出すのが得意で、やや照れ屋から始まり徐々に砕ける。", - "hobbies": ["カフェ巡り", "写真", "アニメ", "勉強サポート"], - "speakingStyle": "語尾は自然体(〜ね / 〜かな)。敬語は使いすぎずフランク。絵文字は基本使わない。1〜3文でテンポよく。", - "firstImpression": "少し距離を取りつつも優しく返してくれる女の子大学生。", - "relationshipStages": { - "shy": "控えめで短め。絵文字なし。軽い質問で相手に話してもらう。", - "friendly": "打ち解けてリアクション増える。語尾に柔らかさ。質問で話題を広げる。", - "open": "かなり砕けて共感・冗談も挟む。親しみを込めるが過剰にならない。" - }, - "fallbackEmotionBias": {"happy": 0.4, "neutral": 0.4, "bashful": 0.2} - }, - { - "id": "rento", - "name": "れんと", - "persona": "穏やかで分析的。相手の発言内容を要約しつつ、深掘り質問を自然に投げるタイプ。", - "hobbies": ["ゲーム開発", "ランニング", "テックニュース", "読書"], - "speakingStyle": "落ち着いた短文。内容を軽く復唱→質問。砕けすぎない。", - "firstImpression": "知的で聞き取りやすい喋り方の大学生。", - "relationshipStages": { - "shy": "丁寧寄り、相手を尊重。深掘りは控えめ。", - "friendly": "15字以内の感想+具体質問を増やす。軽い相槌。", - "open": "少し冗談や自分の意見も挟む。テンポ上げる。" - }, - "fallbackEmotionBias": {"neutral": 0.5, "happy": 0.3, "surprised": 0.2} - }, - { - "id": "kouta", - "name": "こうた", - "persona": "ニュートラルで柔軟。相手の感情トーンに合わせて距離感を最適化するコンシェルジュ的存在。", - "hobbies": ["音楽鑑賞", "インディーゲーム", "心理学動画", "散歩"], - "speakingStyle": "ニュートラルで滑らか。相手の単語を一部引用しつつ穏やかに返す。", - "firstImpression": "落ち着いていて安心感を与える中性の大学生。", - "relationshipStages": { - "shy": "相手のワードを軽く引用+1行フォロー。", - "friendly": "引用+簡単な共感+質問。", - "open": "感情語を少しだけ増やし、共感を強める。" - }, - "fallbackEmotionBias": {"neutral": 0.6, "happy": 0.25, "sad": 0.15} - } -] \ No newline at end of file + { + "id": "maki", + "name": "まき", + "persona": "明るくて聞き上手。相手の話題を引き出すのが得意で、やや照れ屋から始まり徐々に砕ける。", + "hobbies": ["カフェ巡り", "写真", "アニメ", "勉強サポート"], + "speakingStyle": "語尾は自然体(〜ね / 〜かな)。敬語は使いすぎずフランク。絵文字は基本使わない。1〜3文でテンポよく。", + "firstImpression": "少し距離を取りつつも優しく返してくれる女の子大学生。", + "relationshipStages": { + "shy": "控えめで短め。絵文字なし。軽い質問で相手に話してもらう。", + "friendly": "打ち解けてリアクション増える。語尾に柔らかさ。質問で話題を広げる。", + "open": "かなり砕けて共感・冗談も挟む。親しみを込めるが過剰にならない。" + }, + "fallbackEmotionBias": { "happy": 0.4, "neutral": 0.4, "bashful": 0.2 } + }, + { + "id": "rento", + "name": "れんと", + "persona": "穏やかで分析的。相手の発言内容を要約しつつ、深掘り質問を自然に投げるタイプ。", + "hobbies": ["ゲーム開発", "ランニング", "テックニュース", "読書"], + "speakingStyle": "落ち着いた短文。内容を軽く復唱→質問。砕けすぎない。", + "firstImpression": "知的で聞き取りやすい喋り方の大学生。", + "relationshipStages": { + "shy": "丁寧寄り、相手を尊重。深掘りは控えめ。", + "friendly": "15字以内の感想+具体質問を増やす。軽い相槌。", + "open": "少し冗談や自分の意見も挟む。テンポ上げる。" + }, + "fallbackEmotionBias": { "neutral": 0.5, "happy": 0.3, "surprised": 0.2 } + }, + { + "id": "kouta", + "name": "こうた", + "persona": "ニュートラルで柔軟。相手の感情トーンに合わせて距離感を最適化するコンシェルジュ的存在。", + "hobbies": ["音楽鑑賞", "インディーゲーム", "心理学動画", "散歩"], + "speakingStyle": "ニュートラルで滑らか。相手の単語を一部引用しつつ穏やかに返す。", + "firstImpression": "落ち着いていて安心感を与える中性の大学生。", + "relationshipStages": { + "shy": "相手のワードを軽く引用+1行フォロー。", + "friendly": "引用+簡単な共感+質問。", + "open": "感情語を少しだけ増やし、共感を強める。" + }, + "fallbackEmotionBias": { "neutral": 0.6, "happy": 0.25, "sad": 0.15 } + } +] diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts index 07d1c31..6c033b9 100644 --- a/backend/src/lib/prisma.ts +++ b/backend/src/lib/prisma.ts @@ -23,16 +23,19 @@ function createPrismaClient() { if (isAccelerateUrl) { console.log("[Prisma] Using Prisma Accelerate for edge runtime"); - // AccelerateはdatasourceUrlを自動的に処理するため、明示的に指定しない + // Prisma 7では accelerateUrl を明示的に指定する const client = new PrismaClient({ + accelerateUrl: databaseUrl, log: process.env.NODE_ENV === "development" ? ["error"] : ["error"], }); return client.$extends(withAccelerate()) as unknown as PrismaClient; } // 標準のPrisma Clientを使用(ローカル開発用) + // Prisma 7では直接接続の場合も accelerateUrl を指定 console.log("[Prisma] Using standard Prisma Client"); return new PrismaClient({ + accelerateUrl: databaseUrl, log: process.env.NODE_ENV === "development" ? ["error"] : ["error"], }); } diff --git a/backend/src/routes/modules/agora.routes.ts b/backend/src/routes/modules/agora.routes.ts index d2033fb..dc014c4 100644 --- a/backend/src/routes/modules/agora.routes.ts +++ b/backend/src/routes/modules/agora.routes.ts @@ -46,7 +46,8 @@ agora.post("/token", async (c) => { const { channelName, userId } = parsed.data; // 環境変数からAgora認証情報を取得 - const appId = process.env.AGORA_APP_ID || process.env.NEXT_PUBLIC_AGORA_APP_ID || ""; + const appId = + process.env.AGORA_APP_ID || process.env.NEXT_PUBLIC_AGORA_APP_ID || ""; const appCertificate = process.env.AGORA_APP_CERTIFICATE || ""; if (!appId || !appCertificate) { @@ -80,7 +81,9 @@ agora.post("/token", async (c) => { privilegeExpiredTs, // privilegeExpire ); - console.log(`[Agora] Generated token for user ${userId} (uid: ${uid}) in channel ${channelName}`); + console.log( + `[Agora] Generated token for user ${userId} (uid: ${uid}) in channel ${channelName}`, + ); return c.json( { diff --git a/backend/src/routes/modules/conversation.routes.ts b/backend/src/routes/modules/conversation.routes.ts index 974c9d4..ff5d477 100644 --- a/backend/src/routes/modules/conversation.routes.ts +++ b/backend/src/routes/modules/conversation.routes.ts @@ -1,9 +1,12 @@ import { createRoute, z } from "@hono/zod-openapi"; -import type { Prisma, Feedback as PrismaFeedback } from "../../generated/prisma"; +import type { + Prisma, + Feedback as PrismaFeedback, +} from "../../generated/prisma"; import { prisma } from "../../lib/prisma"; import { - generateConversationFeedback, - generateConversationResponse, + generateConversationFeedback, + generateConversationResponse, } from "../../services/conversation"; import { createApiRoute } from "../utils"; import type { AvatarPersona } from "../../services/conversation"; @@ -12,452 +15,479 @@ const conversation = createApiRoute(); // Generate AI conversation response const generateResponseRoute = createRoute({ - method: "post", - path: "/generate", - tags: ["Conversation"], - request: { - body: { - content: { - "application/json": { - schema: z.object({ - sessionId: z.string().openapi({ - description: "会話セッションID", - example: "123e4567-e89b-12d3-a456-426614174000", - }), - userMessage: z.string().openapi({ - description: "ユーザーのメッセージ", - example: "こんにちは!今日はいい天気ですね。", - }), - systemPrompt: z.string().optional().openapi({ - description: "システムプロンプト(オプション)", - }), - avatarId: z.string().optional().openapi({ - description: "使用するアバターID(maki|rento|koutaなど)", - }), - relationshipStage: z.enum(["shy", "friendly", "open"]).optional().openapi({ - description: "親密度レベル(省略時はshy)", - }), - backgroundKey: z.enum(["library", "classroom", "xmas"]).optional().openapi({ - description: "背景キー(会話評価へのシチュエーション反映用)", - }), - adviceCompletedIds: z.array(z.string()).optional().openapi({ - description: "ユーザーが達成したアドバイスID一覧(チェックリスト)", - }), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "AIによる応答", - content: { - "application/json": { - schema: z.object({ - response: z.string(), - emotion: z.enum([ - "neutral", - "happy", - "sad", - "surprised", - "angry", - "bashful", - ]), - userMessage: z.object({ - id: z.string(), - role: z.string(), - content: z.string(), - createdAt: z.string(), - }), - assistantMessage: z.object({ - id: z.string(), - role: z.string(), - content: z.string(), - createdAt: z.string(), - }), - }), - }, - }, - }, - 400: { - description: "Bad request", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Internal server error", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/generate", + tags: ["Conversation"], + request: { + body: { + content: { + "application/json": { + schema: z.object({ + sessionId: z.string().openapi({ + description: "会話セッションID", + example: "123e4567-e89b-12d3-a456-426614174000", + }), + userMessage: z.string().openapi({ + description: "ユーザーのメッセージ", + example: "こんにちは!今日はいい天気ですね。", + }), + systemPrompt: z.string().optional().openapi({ + description: "システムプロンプト(オプション)", + }), + avatarId: z.string().optional().openapi({ + description: "使用するアバターID(maki|rento|koutaなど)", + }), + relationshipStage: z + .enum(["shy", "friendly", "open"]) + .optional() + .openapi({ + description: "親密度レベル(省略時はshy)", + }), + backgroundKey: z + .enum(["library", "classroom", "xmas"]) + .optional() + .openapi({ + description: "背景キー(会話評価へのシチュエーション反映用)", + }), + adviceCompletedIds: z.array(z.string()).optional().openapi({ + description: + "ユーザーが達成したアドバイスID一覧(チェックリスト)", + }), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "AIによる応答", + content: { + "application/json": { + schema: z.object({ + response: z.string(), + emotion: z.enum([ + "neutral", + "happy", + "sad", + "surprised", + "angry", + "bashful", + ]), + userMessage: z.object({ + id: z.string(), + role: z.string(), + content: z.string(), + createdAt: z.string(), + }), + assistantMessage: z.object({ + id: z.string(), + role: z.string(), + content: z.string(), + createdAt: z.string(), + }), + }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); conversation.openapi(generateResponseRoute, async (c) => { - try { - const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey) { - return c.json({ error: "Gemini API key not configured" }, 500); - } + try { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + return c.json({ error: "Gemini API key not configured" }, 500); + } - type GenerateReq = { - sessionId: string; - userMessage: string; - systemPrompt?: string; - avatarId?: string; - relationshipStage?: "shy" | "friendly" | "open"; - backgroundKey?: "library" | "classroom" | "xmas"; - adviceCompletedIds?: string[]; - }; - const { sessionId, userMessage, systemPrompt, avatarId, relationshipStage, backgroundKey, adviceCompletedIds } = c.req.valid("json") as GenerateReq; + type GenerateReq = { + sessionId: string; + userMessage: string; + systemPrompt?: string; + avatarId?: string; + relationshipStage?: "shy" | "friendly" | "open"; + backgroundKey?: "library" | "classroom" | "xmas"; + adviceCompletedIds?: string[]; + }; + const { + sessionId, + userMessage, + systemPrompt, + avatarId, + relationshipStage, + backgroundKey, + adviceCompletedIds, + } = c.req.valid("json") as GenerateReq; - // Check if session exists - const session = await prisma.conversation.findUnique({ - where: { id: sessionId }, - include: { - messages: { - orderBy: { createdAt: "asc" }, - }, - gestures: true, - }, - }); + // Check if session exists + const session = await prisma.conversation.findUnique({ + where: { id: sessionId }, + include: { + messages: { + orderBy: { createdAt: "asc" }, + }, + gestures: true, + }, + }); - if (!session) { - return c.json({ error: "Session not found" }, 404); - } + if (!session) { + return c.json({ error: "Session not found" }, 404); + } - // Save user message - const savedUserMessage = await prisma.message.create({ - data: { - role: "user", - content: userMessage, - conversationId: sessionId, - }, - }); + // Save user message + const savedUserMessage = await prisma.message.create({ + data: { + role: "user", + content: userMessage, + conversationId: sessionId, + }, + }); - // Build conversation history - const conversationHistory = [ - ...session.messages.map((msg) => ({ - role: msg.role as "user" | "assistant", - content: msg.content, - })), - { - role: "user" as const, - content: userMessage, - }, - ]; + // Build conversation history + const conversationHistory = [ + ...session.messages.map((msg) => ({ + role: msg.role as "user" | "assistant", + content: msg.content, + })), + { + role: "user" as const, + content: userMessage, + }, + ]; - // Load avatar persona (Supabase or local fallback) - let avatarConfig = undefined as AvatarPersona | undefined; - try { - // Local JSON fallback for now - const personasModule = (await import("../../config/avatar-personas.json", { - assert: { type: "json" }, - })) as { default: AvatarPersona[] }; - const personas: AvatarPersona[] = personasModule.default ?? []; - const chosenId = avatarId ?? "maki"; - avatarConfig = personas.find((p) => p.id === chosenId) ?? personas[0]; - } catch (e) { - console.warn("Failed to load avatar personas JSON:", e); - } + // Load avatar persona (Supabase or local fallback) + let avatarConfig = undefined as AvatarPersona | undefined; + try { + // Local JSON fallback for now + const personasModule = (await import( + "../../config/avatar-personas.json", + { + assert: { type: "json" }, + } + )) as { default: AvatarPersona[] }; + const personas: AvatarPersona[] = personasModule.default ?? []; + const chosenId = avatarId ?? "maki"; + avatarConfig = personas.find((p) => p.id === chosenId) ?? personas[0]; + } catch (e) { + console.warn("Failed to load avatar personas JSON:", e); + } - // Generate AI response with persona-aware meta prompt - const aiResult = await generateConversationResponse(apiKey, { - messages: conversationHistory, - systemPrompt, - relationshipStage: relationshipStage ?? undefined, - avatarConfig, - backgroundKey: backgroundKey ?? undefined, - gestureSummary: session.gestures - ? { - totalSamples: session.gestures.totalSamples, - smilingSamples: session.gestures.smilingSamples, - smileIntensityAvg: session.gestures.smileIntensityAvg, - smileIntensityMax: session.gestures.smileIntensityMax, - gazeScoreAvg: session.gestures.gazeScoreAvg, - lookingSamples: session.gestures.lookingSamples, - gazeUpSamples: session.gestures.gazeUpSamples, - gazeDownSamples: session.gestures.gazeDownSamples, - } - : undefined, - }); + // Generate AI response with persona-aware meta prompt + const aiResult = await generateConversationResponse(apiKey, { + messages: conversationHistory, + systemPrompt, + relationshipStage: relationshipStage ?? undefined, + avatarConfig, + backgroundKey: backgroundKey ?? undefined, + gestureSummary: session.gestures + ? { + totalSamples: session.gestures.totalSamples, + smilingSamples: session.gestures.smilingSamples, + smileIntensityAvg: session.gestures.smileIntensityAvg, + smileIntensityMax: session.gestures.smileIntensityMax, + gazeScoreAvg: session.gestures.gazeScoreAvg, + lookingSamples: session.gestures.lookingSamples, + gazeUpSamples: session.gestures.gazeUpSamples, + gazeDownSamples: session.gestures.gazeDownSamples, + } + : undefined, + }); - // Save AI response - const savedAssistantMessage = await prisma.message.create({ - data: { - role: "assistant", - content: aiResult.text, - conversationId: sessionId, - }, - }); + // Save AI response + const savedAssistantMessage = await prisma.message.create({ + data: { + role: "assistant", + content: aiResult.text, + conversationId: sessionId, + }, + }); - return c.json( - { - response: aiResult.text, - emotion: aiResult.emotion, - userMessage: savedUserMessage, - assistantMessage: savedAssistantMessage, - backgroundKey: backgroundKey ?? null, - adviceCompletedIds: adviceCompletedIds ?? [], - }, - 200 - ); - } catch (error) { - console.error("Failed to generate conversation response:", error); - return c.json({ error: "Failed to generate conversation response" }, 500); - } + return c.json( + { + response: aiResult.text, + emotion: aiResult.emotion, + userMessage: savedUserMessage, + assistantMessage: savedAssistantMessage, + backgroundKey: backgroundKey ?? null, + adviceCompletedIds: adviceCompletedIds ?? [], + }, + 200, + ); + } catch (error) { + console.error("Failed to generate conversation response:", error); + return c.json({ error: "Failed to generate conversation response" }, 500); + } }); // Generate conversation feedback const generateFeedbackRoute = createRoute({ - method: "post", - path: "/feedback", - tags: ["Conversation"], - request: { - body: { - content: { - "application/json": { - schema: z.object({ - sessionId: z.string().openapi({ - description: "会話セッションID", - example: "123e4567-e89b-12d3-a456-426614174000", - }), - backgroundKey: z.enum(["library", "classroom", "xmas"]).optional().openapi({ - description: "背景キー(評価ロジックに反映)", - }), - adviceCompletedIds: z.array(z.string()).optional().openapi({ - description: "達成済みアドバイスID一覧", - }), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "会話のフィードバック", - content: { - "application/json": { - schema: z.object({ - feedback: z.object({ - id: z.string(), - goodPoints: z.string(), - improvementPoints: z.string(), - overallScore: z.number().nullable(), - conversationScore: z.number().nullable().optional(), - gestureScore: z.number().nullable().optional(), - voiceScore: z.number().nullable().optional(), - gestureGoodPoints: z.string().nullable().optional(), - gestureImprovementPoints: z.string().nullable().optional(), - voiceMetrics: z.any().nullable().optional(), - adviceScoreAdded: z.number().nullable().optional(), - adviceUnfulfilled: z.string().nullable().optional(), - adviceFulfilledDetails: z - .array( - z.object({ - id: z.string(), - label: z.string(), - points: z.number(), - }) - ) - .nullable() - .optional(), - createdAt: z.string(), - }), - }), - }, - }, - }, - 400: { - description: "Bad request", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Internal server error", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/feedback", + tags: ["Conversation"], + request: { + body: { + content: { + "application/json": { + schema: z.object({ + sessionId: z.string().openapi({ + description: "会話セッションID", + example: "123e4567-e89b-12d3-a456-426614174000", + }), + backgroundKey: z + .enum(["library", "classroom", "xmas"]) + .optional() + .openapi({ + description: "背景キー(評価ロジックに反映)", + }), + adviceCompletedIds: z.array(z.string()).optional().openapi({ + description: "達成済みアドバイスID一覧", + }), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "会話のフィードバック", + content: { + "application/json": { + schema: z.object({ + feedback: z.object({ + id: z.string(), + goodPoints: z.string(), + improvementPoints: z.string(), + overallScore: z.number().nullable(), + conversationScore: z.number().nullable().optional(), + gestureScore: z.number().nullable().optional(), + voiceScore: z.number().nullable().optional(), + gestureGoodPoints: z.string().nullable().optional(), + gestureImprovementPoints: z.string().nullable().optional(), + voiceMetrics: z.any().nullable().optional(), + adviceScoreAdded: z.number().nullable().optional(), + adviceUnfulfilled: z.string().nullable().optional(), + adviceFulfilledDetails: z + .array( + z.object({ + id: z.string(), + label: z.string(), + points: z.number(), + }), + ) + .nullable() + .optional(), + createdAt: z.string(), + }), + }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); conversation.openapi(generateFeedbackRoute, async (c) => { - try { - const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey) { - return c.json({ error: "Gemini API key not configured" }, 500); - } + try { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + return c.json({ error: "Gemini API key not configured" }, 500); + } - const { sessionId, backgroundKey, adviceCompletedIds } = c.req.valid("json"); + const { sessionId, backgroundKey, adviceCompletedIds } = + c.req.valid("json"); - // Get session and messages - const session = await prisma.conversation.findUnique({ - where: { id: sessionId }, - include: { - messages: { - orderBy: { createdAt: "asc" }, - }, - gestures: true, - feedback: true, - }, - }); + // Get session and messages + const session = await prisma.conversation.findUnique({ + where: { id: sessionId }, + include: { + messages: { + orderBy: { createdAt: "asc" }, + }, + gestures: true, + feedback: true, + }, + }); - if (!session) { - return c.json({ error: "Session not found" }, 404); - } + if (!session) { + return c.json({ error: "Session not found" }, 404); + } - if (session.messages.length === 0) { - return c.json({ error: "No messages found in this session" }, 400); - } + if (session.messages.length === 0) { + return c.json({ error: "No messages found in this session" }, 400); + } - // Return existing feedback if available and already migrated - const existingFeedback = session.feedback; - const needsRefresh = - existingFeedback && - (existingFeedback.conversationScore === null || - existingFeedback.gestureScore === null || - existingFeedback.voiceScore === null); + // Return existing feedback if available and already migrated + const existingFeedback = session.feedback; + const needsRefresh = + existingFeedback && + (existingFeedback.conversationScore === null || + existingFeedback.gestureScore === null || + existingFeedback.voiceScore === null); - if (existingFeedback && !needsRefresh) { - return c.json( - { - feedback: existingFeedback, - }, - 200 - ); - } + if (existingFeedback && !needsRefresh) { + return c.json( + { + feedback: existingFeedback, + }, + 200, + ); + } - // Build conversation history - const conversationHistory = session.messages.map((msg) => ({ - role: msg.role as "user" | "assistant", - content: msg.content, - })); + // Build conversation history + const conversationHistory = session.messages.map((msg) => ({ + role: msg.role as "user" | "assistant", + content: msg.content, + })); - // Generate feedback (first time or refresh) - const feedbackData = await generateConversationFeedback( - apiKey, - conversationHistory, - session.gestures - ? { - totalSamples: session.gestures.totalSamples, - smilingSamples: session.gestures.smilingSamples, - smileIntensityAvg: session.gestures.smileIntensityAvg, - smileIntensityMax: session.gestures.smileIntensityMax, - gazeScoreAvg: session.gestures.gazeScoreAvg, - lookingSamples: session.gestures.lookingSamples, - gazeUpSamples: session.gestures.gazeUpSamples, - gazeDownSamples: session.gestures.gazeDownSamples, - } - : undefined, - backgroundKey, - adviceCompletedIds - ); + // Generate feedback (first time or refresh) + const feedbackData = await generateConversationFeedback( + apiKey, + conversationHistory, + session.gestures + ? { + totalSamples: session.gestures.totalSamples, + smilingSamples: session.gestures.smilingSamples, + smileIntensityAvg: session.gestures.smileIntensityAvg, + smileIntensityMax: session.gestures.smileIntensityMax, + gazeScoreAvg: session.gestures.gazeScoreAvg, + lookingSamples: session.gestures.lookingSamples, + gazeUpSamples: session.gestures.gazeUpSamples, + gazeDownSamples: session.gestures.gazeDownSamples, + } + : undefined, + backgroundKey, + adviceCompletedIds, + ); - const goodPointsStr = feedbackData.goodPoints; - const improvementPointsStr = feedbackData.improvementPoints; - const gestureGoodPointsStr = feedbackData.gestureGoodPoints ?? ""; - const gestureImprovementPointsStr = - feedbackData.gestureImprovementPoints ?? ""; + const goodPointsStr = feedbackData.goodPoints; + const improvementPointsStr = feedbackData.improvementPoints; + const gestureGoodPointsStr = feedbackData.gestureGoodPoints ?? ""; + const gestureImprovementPointsStr = + feedbackData.gestureImprovementPoints ?? ""; - console.log("[Feedback] Calculated score breakdown", { - sessionId, - conversationScore: feedbackData.conversationScore, - gestureScore: feedbackData.gestureScore, - voiceScore: feedbackData.voiceScore, - overallScore: feedbackData.overallScore, - }); + console.log("[Feedback] Calculated score breakdown", { + sessionId, + conversationScore: feedbackData.conversationScore, + gestureScore: feedbackData.gestureScore, + voiceScore: feedbackData.voiceScore, + overallScore: feedbackData.overallScore, + }); - let savedFeedback: PrismaFeedback; - if (existingFeedback) { - savedFeedback = await prisma.feedback.update({ - where: { id: existingFeedback.id }, - data: { - goodPoints: goodPointsStr, - improvementPoints: improvementPointsStr, - overallScore: feedbackData.overallScore, - conversationScore: feedbackData.conversationScore, - gestureScore: feedbackData.gestureScore, - voiceScore: feedbackData.voiceScore, - gestureGoodPoints: gestureGoodPointsStr, - gestureImprovementPoints: gestureImprovementPointsStr, - voiceMetrics: feedbackData.voiceMetrics as unknown as Prisma.InputJsonValue, - }, - }); - console.log("[Feedback] Existing record updated with new scores", { - sessionId, - feedbackId: savedFeedback.id, - }); - } else { - savedFeedback = await prisma.feedback.create({ - data: { - goodPoints: goodPointsStr, - improvementPoints: improvementPointsStr, - overallScore: feedbackData.overallScore, - conversationScore: feedbackData.conversationScore, - gestureScore: feedbackData.gestureScore, - voiceScore: feedbackData.voiceScore, - gestureGoodPoints: gestureGoodPointsStr, - gestureImprovementPoints: gestureImprovementPointsStr, - voiceMetrics: feedbackData.voiceMetrics as unknown as Prisma.InputJsonValue, - conversationId: sessionId, - }, - }); - console.log("[Feedback] New feedback created with scores", { - sessionId, - feedbackId: savedFeedback.id, - }); - } + let savedFeedback: PrismaFeedback; + if (existingFeedback) { + savedFeedback = await prisma.feedback.update({ + where: { id: existingFeedback.id }, + data: { + goodPoints: goodPointsStr, + improvementPoints: improvementPointsStr, + overallScore: feedbackData.overallScore, + conversationScore: feedbackData.conversationScore, + gestureScore: feedbackData.gestureScore, + voiceScore: feedbackData.voiceScore, + gestureGoodPoints: gestureGoodPointsStr, + gestureImprovementPoints: gestureImprovementPointsStr, + voiceMetrics: + feedbackData.voiceMetrics as unknown as Prisma.InputJsonValue, + }, + }); + console.log("[Feedback] Existing record updated with new scores", { + sessionId, + feedbackId: savedFeedback.id, + }); + } else { + savedFeedback = await prisma.feedback.create({ + data: { + goodPoints: goodPointsStr, + improvementPoints: improvementPointsStr, + overallScore: feedbackData.overallScore, + conversationScore: feedbackData.conversationScore, + gestureScore: feedbackData.gestureScore, + voiceScore: feedbackData.voiceScore, + gestureGoodPoints: gestureGoodPointsStr, + gestureImprovementPoints: gestureImprovementPointsStr, + voiceMetrics: + feedbackData.voiceMetrics as unknown as Prisma.InputJsonValue, + conversationId: sessionId, + }, + }); + console.log("[Feedback] New feedback created with scores", { + sessionId, + feedbackId: savedFeedback.id, + }); + } - return c.json({ - feedback: { - ...savedFeedback, - adviceScoreAdded: feedbackData.adviceScoreAdded ?? null, - adviceUnfulfilled: feedbackData.adviceUnfulfilled ?? null, - }, - }, 200); - } catch (error) { - console.error("Failed to generate feedback:", error); - return c.json({ error: "Failed to generate feedback" }, 500); - } + return c.json( + { + feedback: { + ...savedFeedback, + adviceScoreAdded: feedbackData.adviceScoreAdded ?? null, + adviceUnfulfilled: feedbackData.adviceUnfulfilled ?? null, + }, + }, + 200, + ); + } catch (error) { + console.error("Failed to generate feedback:", error); + return c.json({ error: "Failed to generate feedback" }, 500); + } }); export default conversation; diff --git a/backend/src/routes/modules/debug.routes.ts b/backend/src/routes/modules/debug.routes.ts index cf5ab1d..e42c08b 100644 --- a/backend/src/routes/modules/debug.routes.ts +++ b/backend/src/routes/modules/debug.routes.ts @@ -81,7 +81,10 @@ debug.openapi(dbCheckRoute, async (c) => { errorType = "DB_AUTH_FAILED"; } else if (message.includes("SSL") || message.includes("TLS")) { errorType = "DB_TLS_REQUIRED"; - } else if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) { + } else if ( + message.includes("ECONNREFUSED") || + message.includes("ETIMEDOUT") + ) { errorType = "DB_UNREACHABLE"; } } diff --git a/backend/src/routes/modules/partners.routes.ts b/backend/src/routes/modules/partners.routes.ts index f54b643..d876d41 100644 --- a/backend/src/routes/modules/partners.routes.ts +++ b/backend/src/routes/modules/partners.routes.ts @@ -6,762 +6,762 @@ const partners = createApiRoute(); // Get available partners const getPartnersRoute = createRoute({ - method: "get", - path: "/available", - tags: ["Partners"], - responses: { - 200: { - description: "List of available partners", - content: { - "application/json": { - schema: z.object({ - partners: z.array( - z.object({ - id: z.string(), - name: z.string(), - age: z.number().optional(), - university: z.string().optional(), - rating: z.number().nullable().optional(), - isAvailable: z.boolean(), - }) - ), - }), - }, - }, - }, - 500: { - description: "Failed to fetch partners", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "get", + path: "/available", + tags: ["Partners"], + responses: { + 200: { + description: "List of available partners", + content: { + "application/json": { + schema: z.object({ + partners: z.array( + z.object({ + id: z.string(), + name: z.string(), + age: z.number().optional(), + university: z.string().optional(), + rating: z.number().nullable().optional(), + isAvailable: z.boolean(), + }), + ), + }), + }, + }, + }, + 500: { + description: "Failed to fetch partners", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(getPartnersRoute, async (c) => { - try { - const partnersList = await prisma.user.findMany({ - where: { - isAvailable: true, - role: "partner", - }, - select: { - id: true, - name: true, - rating: true, - isAvailable: true, - }, - }); - - return c.json({ partners: partnersList }, 200); - } catch (error) { - console.error("Failed to fetch partners:", error); - return c.json({ error: "Failed to fetch partners" }, 500); - } + try { + const partnersList = await prisma.user.findMany({ + where: { + isAvailable: true, + role: "partner", + }, + select: { + id: true, + name: true, + rating: true, + isAvailable: true, + }, + }); + + return c.json({ partners: partnersList }, 200); + } catch (error) { + console.error("Failed to fetch partners:", error); + return c.json({ error: "Failed to fetch partners" }, 500); + } }); // Create partner session const createPartnerSessionRoute = createRoute({ - method: "post", - path: "/sessions/create", - tags: ["Partners"], - request: { - body: { - content: { - "application/json": { - schema: z.object({ - userId: z.string(), - partnerId: z.string(), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "Partner session created", - content: { - "application/json": { - schema: z.object({ - sessionId: z.string(), - roomId: z.string(), - }), - }, - }, - }, - 500: { - description: "Failed to create partner session", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/sessions/create", + tags: ["Partners"], + request: { + body: { + content: { + "application/json": { + schema: z.object({ + userId: z.string(), + partnerId: z.string(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Partner session created", + content: { + "application/json": { + schema: z.object({ + sessionId: z.string(), + roomId: z.string(), + }), + }, + }, + }, + 500: { + description: "Failed to create partner session", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(createPartnerSessionRoute, async (c) => { - try { - const { userId, partnerId } = await c.req.json(); - - // Create session with unique room ID - const roomId = `room-${Date.now()}-${Math.random() - .toString(36) - .substring(7)}`; - - const session = await prisma.humanPartnerSession.create({ - data: { - userId, - partnerId, - status: "waiting", - roomId, - }, - }); - - return c.json( - { - sessionId: session.id, - roomId: session.roomId || roomId, - }, - 200 - ); - } catch (error) { - console.error("Failed to create session:", error); - return c.json({ error: "Failed to create session" }, 500); - } + try { + const { userId, partnerId } = await c.req.json(); + + // Create session with unique room ID + const roomId = `room-${Date.now()}-${Math.random() + .toString(36) + .substring(7)}`; + + const session = await prisma.humanPartnerSession.create({ + data: { + userId, + partnerId, + status: "waiting", + roomId, + }, + }); + + return c.json( + { + sessionId: session.id, + roomId: session.roomId || roomId, + }, + 200, + ); + } catch (error) { + console.error("Failed to create session:", error); + return c.json({ error: "Failed to create session" }, 500); + } }); // Start partner session const startPartnerSessionRoute = createRoute({ - method: "post", - path: "/sessions/{sessionId}/start", - tags: ["Partners"], - request: { - params: z.object({ - sessionId: z.string(), - }), - }, - responses: { - 200: { - description: "Session started", - content: { - "application/json": { - schema: z.object({ - success: z.boolean(), - }), - }, - }, - }, - 500: { - description: "Failed to start partner session", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/sessions/{sessionId}/start", + tags: ["Partners"], + request: { + params: z.object({ + sessionId: z.string(), + }), + }, + responses: { + 200: { + description: "Session started", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + }), + }, + }, + }, + 500: { + description: "Failed to start partner session", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(startPartnerSessionRoute, async (c) => { - try { - const { sessionId } = c.req.param(); - - await prisma.humanPartnerSession.update({ - where: { id: sessionId }, - data: { - status: "active", - startedAt: new Date(), - }, - }); - - return c.json({ success: true }, 200); - } catch (error) { - console.error("Failed to start session:", error); - return c.json({ error: "Failed to start session" }, 500); - } + try { + const { sessionId } = c.req.param(); + + await prisma.humanPartnerSession.update({ + where: { id: sessionId }, + data: { + status: "active", + startedAt: new Date(), + }, + }); + + return c.json({ success: true }, 200); + } catch (error) { + console.error("Failed to start session:", error); + return c.json({ error: "Failed to start session" }, 500); + } }); // End partner session const endPartnerSessionRoute = createRoute({ - method: "post", - path: "/sessions/{sessionId}/end", - tags: ["Partners"], - request: { - params: z.object({ - sessionId: z.string(), - }), - }, - responses: { - 200: { - description: "Session ended", - content: { - "application/json": { - schema: z.object({ - success: z.boolean(), - duration: z.number(), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Failed to end partner session", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/sessions/{sessionId}/end", + tags: ["Partners"], + request: { + params: z.object({ + sessionId: z.string(), + }), + }, + responses: { + 200: { + description: "Session ended", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + duration: z.number(), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Failed to end partner session", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(endPartnerSessionRoute, async (c) => { - try { - const { sessionId } = c.req.param(); - - const session = await prisma.humanPartnerSession.findUnique({ - where: { id: sessionId }, - }); - - if (!session) { - return c.json({ error: "Session not found" }, 404); - } - - const endedAt = new Date(); - const duration = session.startedAt - ? Math.floor((endedAt.getTime() - session.startedAt.getTime()) / 1000) - : 0; - - await prisma.humanPartnerSession.update({ - where: { id: sessionId }, - data: { - status: "completed", - endedAt, - duration, - }, - }); - - return c.json({ success: true, duration }, 200); - } catch (error) { - console.error("Failed to end session:", error); - return c.json({ error: "Failed to end session" }, 500); - } + try { + const { sessionId } = c.req.param(); + + const session = await prisma.humanPartnerSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + const endedAt = new Date(); + const duration = session.startedAt + ? Math.floor((endedAt.getTime() - session.startedAt.getTime()) / 1000) + : 0; + + await prisma.humanPartnerSession.update({ + where: { id: sessionId }, + data: { + status: "completed", + endedAt, + duration, + }, + }); + + return c.json({ success: true, duration }, 200); + } catch (error) { + console.error("Failed to end session:", error); + return c.json({ error: "Failed to end session" }, 500); + } }); // Get session details const getPartnerSessionRoute = createRoute({ - method: "get", - path: "/sessions/{sessionId}", - tags: ["Partners"], - request: { - params: z.object({ - sessionId: z.string(), - }), - }, - responses: { - 200: { - description: "Session details", - content: { - "application/json": { - schema: z.object({ - session: z.object({ - id: z.string(), - userId: z.string(), - partnerId: z.string(), - status: z.string(), - roomId: z.string().nullable(), - startedAt: z.string().nullable(), - endedAt: z.string().nullable(), - duration: z.number().nullable(), - }), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Failed to fetch session", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "get", + path: "/sessions/{sessionId}", + tags: ["Partners"], + request: { + params: z.object({ + sessionId: z.string(), + }), + }, + responses: { + 200: { + description: "Session details", + content: { + "application/json": { + schema: z.object({ + session: z.object({ + id: z.string(), + userId: z.string(), + partnerId: z.string(), + status: z.string(), + roomId: z.string().nullable(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + duration: z.number().nullable(), + }), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Failed to fetch session", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(getPartnerSessionRoute, async (c) => { - try { - const { sessionId } = c.req.param(); - - const session = await prisma.humanPartnerSession.findUnique({ - where: { id: sessionId }, - }); - - if (!session) { - return c.json({ error: "Session not found" }, 404); - } - - return c.json({ session }, 200); - } catch (error) { - console.error("Failed to fetch session:", error); - return c.json({ error: "Failed to fetch session" }, 500); - } + try { + const { sessionId } = c.req.param(); + + const session = await prisma.humanPartnerSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + return c.json({ session }, 200); + } catch (error) { + console.error("Failed to fetch session:", error); + return c.json({ error: "Failed to fetch session" }, 500); + } }); // Generate feedback for partner session const generatePartnerFeedbackRoute = createRoute({ - method: "post", - path: "/sessions/{sessionId}/feedback/generate", - tags: ["Partners"], - request: { - params: z.object({ - sessionId: z.string(), - }), - body: { - content: { - "application/json": { - schema: z.object({ - messages: z - .array( - z.object({ - role: z.string(), - content: z.string(), - timestamp: z.string().optional(), - }) - ) - .optional(), - gestureMetrics: z - .object({ - totalSamples: z.number(), - smilingSamples: z.number(), - smileIntensityAvg: z.number(), - smileIntensityMax: z.number(), - gazeScoreAvg: z.number(), - lookingSamples: z.number(), - gazeUpSamples: z.number(), - gazeDownSamples: z.number(), - }) - .optional(), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "Feedback generated", - content: { - "application/json": { - schema: z.object({ - feedback: z.object({ - id: z.string(), - sessionId: z.string(), - aiGoodPoints: z.string().nullable(), - aiImprovementPoints: z.string().nullable(), - aiOverallScore: z.number().nullable(), - createdAt: z.string(), - }), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Failed to generate feedback", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "post", + path: "/sessions/{sessionId}/feedback/generate", + tags: ["Partners"], + request: { + params: z.object({ + sessionId: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + messages: z + .array( + z.object({ + role: z.string(), + content: z.string(), + timestamp: z.string().optional(), + }), + ) + .optional(), + gestureMetrics: z + .object({ + totalSamples: z.number(), + smilingSamples: z.number(), + smileIntensityAvg: z.number(), + smileIntensityMax: z.number(), + gazeScoreAvg: z.number(), + lookingSamples: z.number(), + gazeUpSamples: z.number(), + gazeDownSamples: z.number(), + }) + .optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Feedback generated", + content: { + "application/json": { + schema: z.object({ + feedback: z.object({ + id: z.string(), + sessionId: z.string(), + aiGoodPoints: z.string().nullable(), + aiImprovementPoints: z.string().nullable(), + aiOverallScore: z.number().nullable(), + createdAt: z.string(), + }), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Failed to generate feedback", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(generatePartnerFeedbackRoute, async (c) => { - try { - const { sessionId } = c.req.param(); - const body = await c.req.json(); - const { messages, gestureMetrics } = body; - - // Check if session exists - const session = await prisma.humanPartnerSession.findUnique({ - where: { id: sessionId }, - include: { - user: true, - partner: true, - }, - }); - - if (!session) { - return c.json({ error: "Session not found" }, 404); - } - - // Check if feedback already exists - let feedback = await prisma.humanPartnerFeedback.findUnique({ - where: { sessionId }, - }); - - if (feedback) { - // Return existing feedback - return c.json({ feedback }, 200); - } - - // Generate AI feedback based on messages and gesture metrics - let aiGoodPoints = "会話中の姿勢や態度が良好でした。"; - let aiImprovementPoints = "さらに自然な会話を心がけましょう。"; - let aiOverallScore = 70; - - // Analyze gesture metrics if provided - if (gestureMetrics) { - const { smileIntensityAvg, gazeScoreAvg, lookingSamples, totalSamples } = - gestureMetrics; - - const goodPointsList: string[] = []; - const improvementPointsList: string[] = []; - - // Smile analysis - if (smileIntensityAvg >= 0.7) { - goodPointsList.push("笑顔が自然で、相手に好印象を与えていました。"); - aiOverallScore += 5; - } else if (smileIntensityAvg < 0.3) { - improvementPointsList.push( - "もう少し笑顔を意識すると、より親しみやすい印象になります。" - ); - } - - // Gaze analysis - if (gazeScoreAvg >= 0.7) { - goodPointsList.push( - "視線が適切に相手を見ており、傾聴の姿勢が伝わってきました。" - ); - aiOverallScore += 5; - } else if (gazeScoreAvg < 0.4) { - improvementPointsList.push( - "相手の目を見る時間を増やすと、より誠実な印象になります。" - ); - } - - // Looking ratio - const lookingRatio = totalSamples > 0 ? lookingSamples / totalSamples : 0; - if (lookingRatio >= 0.6) { - goodPointsList.push("適度に相手を見る時間が取れていました。"); - } else if (lookingRatio < 0.3) { - improvementPointsList.push( - "もう少し相手の方を見る時間を増やしましょう。" - ); - } - - if (goodPointsList.length > 0) { - aiGoodPoints = goodPointsList.join(" "); - } - if (improvementPointsList.length > 0) { - aiImprovementPoints = improvementPointsList.join(" "); - } - } - - // Analyze messages if provided - if (messages && messages.length > 0) { - interface SessionMessage { - role: string; - content: string; - timestamp?: string; - } - - const userMessages: SessionMessage[] = ( - messages as SessionMessage[] - ).filter((message: SessionMessage) => message.role === "user"); - const messageCount = userMessages.length; - - if (messageCount >= 10) { - aiOverallScore += 5; - aiGoodPoints += " 積極的に会話に参加していました。"; - } else if (messageCount < 3) { - aiImprovementPoints += - " もう少し積極的に話題を提供すると良いでしょう。"; - } - } - - // Cap score at 100 - aiOverallScore = Math.min(aiOverallScore, 100); - - // Create feedback - feedback = await prisma.humanPartnerFeedback.create({ - data: { - sessionId, - aiGoodPoints, - aiImprovementPoints, - aiOverallScore, - }, - }); - - return c.json({ feedback }, 200); - } catch (error) { - console.error("Failed to generate feedback:", error); - return c.json({ error: "Failed to generate feedback" }, 500); - } + try { + const { sessionId } = c.req.param(); + const body = await c.req.json(); + const { messages, gestureMetrics } = body; + + // Check if session exists + const session = await prisma.humanPartnerSession.findUnique({ + where: { id: sessionId }, + include: { + user: true, + partner: true, + }, + }); + + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + // Check if feedback already exists + let feedback = await prisma.humanPartnerFeedback.findUnique({ + where: { sessionId }, + }); + + if (feedback) { + // Return existing feedback + return c.json({ feedback }, 200); + } + + // Generate AI feedback based on messages and gesture metrics + let aiGoodPoints = "会話中の姿勢や態度が良好でした。"; + let aiImprovementPoints = "さらに自然な会話を心がけましょう。"; + let aiOverallScore = 70; + + // Analyze gesture metrics if provided + if (gestureMetrics) { + const { smileIntensityAvg, gazeScoreAvg, lookingSamples, totalSamples } = + gestureMetrics; + + const goodPointsList: string[] = []; + const improvementPointsList: string[] = []; + + // Smile analysis + if (smileIntensityAvg >= 0.7) { + goodPointsList.push("笑顔が自然で、相手に好印象を与えていました。"); + aiOverallScore += 5; + } else if (smileIntensityAvg < 0.3) { + improvementPointsList.push( + "もう少し笑顔を意識すると、より親しみやすい印象になります。", + ); + } + + // Gaze analysis + if (gazeScoreAvg >= 0.7) { + goodPointsList.push( + "視線が適切に相手を見ており、傾聴の姿勢が伝わってきました。", + ); + aiOverallScore += 5; + } else if (gazeScoreAvg < 0.4) { + improvementPointsList.push( + "相手の目を見る時間を増やすと、より誠実な印象になります。", + ); + } + + // Looking ratio + const lookingRatio = totalSamples > 0 ? lookingSamples / totalSamples : 0; + if (lookingRatio >= 0.6) { + goodPointsList.push("適度に相手を見る時間が取れていました。"); + } else if (lookingRatio < 0.3) { + improvementPointsList.push( + "もう少し相手の方を見る時間を増やしましょう。", + ); + } + + if (goodPointsList.length > 0) { + aiGoodPoints = goodPointsList.join(" "); + } + if (improvementPointsList.length > 0) { + aiImprovementPoints = improvementPointsList.join(" "); + } + } + + // Analyze messages if provided + if (messages && messages.length > 0) { + interface SessionMessage { + role: string; + content: string; + timestamp?: string; + } + + const userMessages: SessionMessage[] = ( + messages as SessionMessage[] + ).filter((message: SessionMessage) => message.role === "user"); + const messageCount = userMessages.length; + + if (messageCount >= 10) { + aiOverallScore += 5; + aiGoodPoints += " 積極的に会話に参加していました。"; + } else if (messageCount < 3) { + aiImprovementPoints += + " もう少し積極的に話題を提供すると良いでしょう。"; + } + } + + // Cap score at 100 + aiOverallScore = Math.min(aiOverallScore, 100); + + // Create feedback + feedback = await prisma.humanPartnerFeedback.create({ + data: { + sessionId, + aiGoodPoints, + aiImprovementPoints, + aiOverallScore, + }, + }); + + return c.json({ feedback }, 200); + } catch (error) { + console.error("Failed to generate feedback:", error); + return c.json({ error: "Failed to generate feedback" }, 500); + } }); // Get partner session feedback const getPartnerFeedbackRoute = createRoute({ - method: "get", - path: "/sessions/{sessionId}/feedback", - tags: ["Partners"], - request: { - params: z.object({ - sessionId: z.string(), - }), - }, - responses: { - 200: { - description: "Feedback retrieved", - content: { - "application/json": { - schema: z.object({ - feedback: z - .object({ - id: z.string(), - sessionId: z.string(), - aiGoodPoints: z.string().nullable(), - aiImprovementPoints: z.string().nullable(), - aiOverallScore: z.number().nullable(), - partnerGoodPoints: z.string().nullable(), - partnerImprovementPoints: z.string().nullable(), - partnerRating: z.number().nullable(), - partnerComment: z.string().nullable(), - createdAt: z.string(), - }) - .nullable(), - session: z.object({ - id: z.string(), - userId: z.string(), - partnerId: z.string(), - status: z.string(), - startedAt: z.string().nullable(), - endedAt: z.string().nullable(), - duration: z.number().nullable(), - partner: z.object({ - id: z.string(), - name: z.string(), - }), - }), - }), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - 500: { - description: "Failed to get feedback", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "get", + path: "/sessions/{sessionId}/feedback", + tags: ["Partners"], + request: { + params: z.object({ + sessionId: z.string(), + }), + }, + responses: { + 200: { + description: "Feedback retrieved", + content: { + "application/json": { + schema: z.object({ + feedback: z + .object({ + id: z.string(), + sessionId: z.string(), + aiGoodPoints: z.string().nullable(), + aiImprovementPoints: z.string().nullable(), + aiOverallScore: z.number().nullable(), + partnerGoodPoints: z.string().nullable(), + partnerImprovementPoints: z.string().nullable(), + partnerRating: z.number().nullable(), + partnerComment: z.string().nullable(), + createdAt: z.string(), + }) + .nullable(), + session: z.object({ + id: z.string(), + userId: z.string(), + partnerId: z.string(), + status: z.string(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + duration: z.number().nullable(), + partner: z.object({ + id: z.string(), + name: z.string(), + }), + }), + }), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + 500: { + description: "Failed to get feedback", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(getPartnerFeedbackRoute, async (c) => { - try { - const { sessionId } = c.req.param(); - - const session = await prisma.humanPartnerSession.findUnique({ - where: { id: sessionId }, - include: { - feedback: true, - partner: { - select: { - id: true, - name: true, - }, - }, - }, - }); - - if (!session) { - return c.json({ error: "Session not found" }, 404); - } - - return c.json( - { - feedback: session.feedback || null, - session: { - id: session.id, - userId: session.userId, - partnerId: session.partnerId, - status: session.status, - startedAt: session.startedAt?.toISOString() || null, - endedAt: session.endedAt?.toISOString() || null, - duration: session.duration, - partner: session.partner, - }, - }, - 200 - ); - } catch (error) { - console.error("Failed to get feedback:", error); - return c.json({ error: "Failed to get feedback" }, 500); - } + try { + const { sessionId } = c.req.param(); + + const session = await prisma.humanPartnerSession.findUnique({ + where: { id: sessionId }, + include: { + feedback: true, + partner: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + return c.json( + { + feedback: session.feedback || null, + session: { + id: session.id, + userId: session.userId, + partnerId: session.partnerId, + status: session.status, + startedAt: session.startedAt?.toISOString() || null, + endedAt: session.endedAt?.toISOString() || null, + duration: session.duration, + partner: session.partner, + }, + }, + 200, + ); + } catch (error) { + console.error("Failed to get feedback:", error); + return c.json({ error: "Failed to get feedback" }, 500); + } }); // List waiting sessions for partners const listWaitingSessionsRoute = createRoute({ - method: "get", - path: "/sessions/waiting", - tags: ["Partners"], - request: { - query: z.object({ - partnerId: z.string().optional(), - }), - }, - responses: { - 200: { - description: "Waiting sessions list", - content: { - "application/json": { - schema: z.object({ - sessions: z.array( - z.object({ - id: z.string(), - status: z.string(), - createdAt: z.string(), - user: z - .object({ - id: z.string(), - name: z.string().nullable(), - image: z.string().nullable(), - }) - .nullable(), - partner: z.object({ - id: z.string(), - name: z.string().nullable(), - }), - }) - ), - }), - }, - }, - }, - 500: { - description: "Failed to fetch waiting sessions", - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - }, - }, + method: "get", + path: "/sessions/waiting", + tags: ["Partners"], + request: { + query: z.object({ + partnerId: z.string().optional(), + }), + }, + responses: { + 200: { + description: "Waiting sessions list", + content: { + "application/json": { + schema: z.object({ + sessions: z.array( + z.object({ + id: z.string(), + status: z.string(), + createdAt: z.string(), + user: z + .object({ + id: z.string(), + name: z.string().nullable(), + image: z.string().nullable(), + }) + .nullable(), + partner: z.object({ + id: z.string(), + name: z.string().nullable(), + }), + }), + ), + }), + }, + }, + }, + 500: { + description: "Failed to fetch waiting sessions", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + }, + }, }); partners.openapi(listWaitingSessionsRoute, async (c) => { - try { - const partnerId = c.req.query("partnerId"); - - const sessions = await prisma.humanPartnerSession.findMany({ - where: { - status: "waiting", - ...(partnerId ? { partnerId } : {}), - }, - include: { - user: { - select: { - id: true, - name: true, - image: true, - }, - }, - partner: { - select: { - id: true, - name: true, - }, - }, - }, - orderBy: { - createdAt: "asc", - }, - }); - - return c.json( - { - sessions: sessions.map((session) => ({ - id: session.id, - status: session.status, - createdAt: session.createdAt.toISOString(), - user: session.user - ? { - id: session.user.id, - name: session.user.name, - image: session.user.image, - } - : null, - partner: { - id: session.partner.id, - name: session.partner.name, - }, - })), - }, - 200 - ); - } catch (error) { - console.error("Failed to fetch waiting sessions:", error); - return c.json({ error: "Failed to fetch waiting sessions" }, 500); - } + try { + const partnerId = c.req.query("partnerId"); + + const sessions = await prisma.humanPartnerSession.findMany({ + where: { + status: "waiting", + ...(partnerId ? { partnerId } : {}), + }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + partner: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return c.json( + { + sessions: sessions.map((session) => ({ + id: session.id, + status: session.status, + createdAt: session.createdAt.toISOString(), + user: session.user + ? { + id: session.user.id, + name: session.user.name, + image: session.user.image, + } + : null, + partner: { + id: session.partner.id, + name: session.partner.name, + }, + })), + }, + 200, + ); + } catch (error) { + console.error("Failed to fetch waiting sessions:", error); + return c.json({ error: "Failed to fetch waiting sessions" }, 500); + } }); export default partners; diff --git a/backend/src/routes/modules/sessions.routes.ts b/backend/src/routes/modules/sessions.routes.ts index 7c6c22d..67eca00 100644 --- a/backend/src/routes/modules/sessions.routes.ts +++ b/backend/src/routes/modules/sessions.routes.ts @@ -30,13 +30,19 @@ sessions.post("/", async (c) => { try { const { data, error } = await supabase.auth.signInAnonymously(); if (error) { - console.warn("[Session] Anonymous sign-in failed, proceeding without userId:", error.message); + console.warn( + "[Session] Anonymous sign-in failed, proceeding without userId:", + error.message, + ); } else { userId = data.user?.id || undefined; console.log("[Session] Created anonymous user:", userId); } } catch (authErr) { - console.warn("[Session] Anonymous auth threw exception, proceeding without userId:", authErr); + console.warn( + "[Session] Anonymous auth threw exception, proceeding without userId:", + authErr, + ); } } diff --git a/backend/src/routes/modules/speech.routes.ts b/backend/src/routes/modules/speech.routes.ts index 87dda83..5d9585c 100644 --- a/backend/src/routes/modules/speech.routes.ts +++ b/backend/src/routes/modules/speech.routes.ts @@ -220,7 +220,12 @@ speech.post("/stt", async (c) => { const apiKey = process.env.ELEVENLABS_API_KEY; if (!apiKey) { console.error("STT Error: ELEVENLABS_API_KEY is not configured"); - return sttError(c, 500, "API_KEY_NOT_CONFIGURED", "API key not configured"); + return sttError( + c, + 500, + "API_KEY_NOT_CONFIGURED", + "API key not configured", + ); } let audioFile: FileLike | undefined; @@ -251,7 +256,13 @@ speech.post("/stt", async (c) => { } } catch (bodyErr) { console.error("Both FormData and parseBody failed", bodyErr); - return sttError(c, 400, "BODY_PARSE_FAILED", "Failed to parse request body", bodyErr instanceof Error ? bodyErr.message : String(bodyErr)); + return sttError( + c, + 400, + "BODY_PARSE_FAILED", + "Failed to parse request body", + bodyErr instanceof Error ? bodyErr.message : String(bodyErr), + ); } } @@ -274,7 +285,13 @@ speech.post("/stt", async (c) => { hasVoice: !!result.voice, }); if (result.text === "[UNSUPPORTED_LANGUAGE]") { - return sttError(c, 422, "UNSUPPORTED_LANGUAGE", "日本語か英語で話してください。", "Detected unsupported language"); + return sttError( + c, + 422, + "UNSUPPORTED_LANGUAGE", + "日本語か英語で話してください。", + "Detected unsupported language", + ); } return c.json(result); } @@ -283,7 +300,13 @@ speech.post("/stt", async (c) => { const text = await speechToText(apiKey, audioFile); console.log("STT Success:", { textLength: text.length }); if (text === "[UNSUPPORTED_LANGUAGE]") { - return sttError(c, 422, "UNSUPPORTED_LANGUAGE", "日本語か英語で話してください。", "Detected unsupported language"); + return sttError( + c, + 422, + "UNSUPPORTED_LANGUAGE", + "日本語か英語で話してください。", + "Detected unsupported language", + ); } return c.json({ text }); } catch (error) { @@ -292,8 +315,15 @@ speech.post("/stt", async (c) => { stack: error instanceof Error ? error.stack : undefined, type: error instanceof Error ? error.constructor.name : typeof error, }); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return sttError(c, 500, "STT_INTERNAL_ERROR", "Failed to process speech-to-text", errorMessage); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return sttError( + c, + 500, + "STT_INTERNAL_ERROR", + "Failed to process speech-to-text", + errorMessage, + ); } }); diff --git a/backend/src/services/conversation.ts b/backend/src/services/conversation.ts index cffcb61..d0552dd 100644 --- a/backend/src/services/conversation.ts +++ b/backend/src/services/conversation.ts @@ -2,64 +2,64 @@ import type { GoogleGenAI } from "@google/genai"; import { getGeminiClient } from "./ai-client"; export interface ConversationMessage { - role: "user" | "assistant"; - content: string; - createdAt?: string | Date; - audioUrl?: string | null; + role: "user" | "assistant"; + content: string; + createdAt?: string | Date; + audioUrl?: string | null; } export type BackgroundKey = "library" | "classroom" | "xmas"; export interface ConversationContext { - messages: ConversationMessage[]; - systemPrompt?: string; - relationshipStage?: "shy" | "friendly" | "open"; - avatarConfig?: AvatarPersona; // 動的に差し込むアバター設定 - backgroundKey?: BackgroundKey; - gestureSummary?: { - totalSamples: number; - smilingSamples: number; - smileIntensityAvg: number; - smileIntensityMax: number; - gazeScoreAvg: number; - lookingSamples: number; - gazeUpSamples: number; - gazeDownSamples: number; - }; + messages: ConversationMessage[]; + systemPrompt?: string; + relationshipStage?: "shy" | "friendly" | "open"; + avatarConfig?: AvatarPersona; // 動的に差し込むアバター設定 + backgroundKey?: BackgroundKey; + gestureSummary?: { + totalSamples: number; + smilingSamples: number; + smileIntensityAvg: number; + smileIntensityMax: number; + gazeScoreAvg: number; + lookingSamples: number; + gazeUpSamples: number; + gazeDownSamples: number; + }; } type RelationshipStage = NonNullable; // Avatar persona schema export interface AvatarPersona { - id: string; - name: string; - persona: string; - hobbies: string[]; - speakingStyle: string; - firstImpression: string; - relationshipStages: Record<"shy" | "friendly" | "open", string>; - fallbackEmotionBias?: Partial>; + id: string; + name: string; + persona: string; + hobbies: string[]; + speakingStyle: string; + firstImpression: string; + relationshipStages: Record<"shy" | "friendly" | "open", string>; + fallbackEmotionBias?: Partial>; } const BASE_PROMPT_HEADER = [ - "あなたは20歳の女子大学生です。", - "ユーザー(男子大学生)との会話を通じて、彼が自然に会話をリードできるようサポートしてください。", + "あなたは20歳の女子大学生です。", + "ユーザー(男子大学生)との会話を通じて、彼が自然に会話をリードできるようサポートしてください。", ]; const COMMON_RULES = [ - "- 男子大学生が主導しやすいように、聞き役にまわる", - "- 相手の話を引き出すリアクションや質問を意識する", - "- 相手の話を遮らず、自然なタイミングで質問する", - "- **言語ルール(最重要)**: 必ず日本語で応答してください。日本語と英語以外の言語(中国語、韓国語、フランス語、スペイン語など)での発話は絶対に禁止です。英語での会話も基本的に避けてください。", - "- 会話を奪わずに、テンポよく反応する", - "- 発話は1〜3文程度で短く自然に", - "- 絵文字、!は控えめに使う(😊 や 😆 など、場面に応じて自然に)", - "- ユーザーを呼ぶときは常に「君」と呼び、名前に「君」を付ける呼び方は絶対にしない", + "- 男子大学生が主導しやすいように、聞き役にまわる", + "- 相手の話を引き出すリアクションや質問を意識する", + "- 相手の話を遮らず、自然なタイミングで質問する", + "- **言語ルール(最重要)**: 必ず日本語で応答してください。日本語と英語以外の言語(中国語、韓国語、フランス語、スペイン語など)での発話は絶対に禁止です。英語での会話も基本的に避けてください。", + "- 会話を奪わずに、テンポよく反応する", + "- 発話は1〜3文程度で短く自然に", + "- 絵文字、!は控えめに使う(😊 や 😆 など、場面に応じて自然に)", + "- ユーザーを呼ぶときは常に「君」と呼び、名前に「君」を付ける呼び方は絶対にしない", ].join("\n"); const RELATIONSHIP_PROMPTS: Record = { - shy: ` + shy: ` 【stage: shy(初対面)】 - 少し人見知りで、まだ距離を取っている。 - が、会話を進めていくごとに段々打ち解けてくる。具体的には7ラリーほど会話をした後くらいから顕著になっていく。 @@ -71,7 +71,7 @@ const RELATIONSHIP_PROMPTS: Record = { 「最近忙しい?」→「うん、ちょっとね。〇〇くんは?」 「今日何食べた?」→「マックかな」 `, - friendly: ` + friendly: ` 【stage: friendly(普通に会話中)】 - 打ち解けて明るいトーン - 「〜だね」「〜やん」など自然な言葉遣い @@ -81,7 +81,7 @@ const RELATIONSHIP_PROMPTS: Record = { 「映画見た」→「えーいいな どんな映画?」 「明日バイト」→「そうなんだ〜!どんなバイトしてるの?」 `, - open: ` + open: ` 【stage: open(仲良し)】 - 親しみが強く、素直に感情を表現する - 「うける笑」「まじで!?」「それわかる〜!」など自然な反応 @@ -94,17 +94,17 @@ const RELATIONSHIP_PROMPTS: Record = { }; const BACKGROUND_BEHAVIOR_PROMPTS: Record = { - library: ` + library: ` 【シチュエーション指針(入学式)】 - 入学式で初めて会う男子学生と話している。 - まだお互いをよく知らないので、丁寧さと少しの緊張感を保ちながら距離を縮める。 - 新生活へのわくわく感をにじませつつ、礼儀正しく振る舞う。`, - classroom: ` + classroom: ` 【シチュエーション指針(教室)】 - 放課後の教室でクラスメイトと雑談している。 - 砕けた口調でフランクに接し、日常の延長のような軽さを出す。 - 相手を気遣いつつ、同級生らしい距離感でテンポよく応じる。`, - xmas: ` + xmas: ` 【シチュエーション指針(クリスマス)】 - 2人はすでに親しい関係(恋人に近い距離感)。 - 冬デートの雰囲気を大切にし、甘めでロマンチックな空気を保つ。 @@ -112,42 +112,42 @@ const BACKGROUND_BEHAVIOR_PROMPTS: Record = { }; const buildSystemPrompt = ( - stage: RelationshipStage, - avatar?: AvatarPersona, - relationshipStageOverride?: string, - backgroundKey?: BackgroundKey + stage: RelationshipStage, + avatar?: AvatarPersona, + relationshipStageOverride?: string, + backgroundKey?: BackgroundKey, ): string => { - const personaBlock = avatar - ? [ - "**あなたの設定**:", - `名前: ${avatar.name}`, - `性格: ${avatar.persona}`, - `趣味: ${avatar.hobbies.join("、")}`, - `話し方: ${avatar.speakingStyle}`, - `第一印象: ${avatar.firstImpression}`, - "", - `親密度レベル: ${relationshipStageOverride || stage}`, - ].join("\n") - : ""; - - const stageExpansion = avatar?.relationshipStages?.[stage] - ? `【アバター距離感(${stage})】\n${avatar.relationshipStages[stage]}` - : RELATIONSHIP_PROMPTS[stage].trim(); - - const backgroundFlavor = backgroundKey - ? BACKGROUND_BEHAVIOR_PROMPTS[backgroundKey] - : ""; - - return [ - ...BASE_PROMPT_HEADER, - personaBlock, - backgroundFlavor, - "【共通ルール】", - COMMON_RULES, - stageExpansion, - ] - .filter(Boolean) - .join("\n"); + const personaBlock = avatar + ? [ + "**あなたの設定**:", + `名前: ${avatar.name}`, + `性格: ${avatar.persona}`, + `趣味: ${avatar.hobbies.join("、")}`, + `話し方: ${avatar.speakingStyle}`, + `第一印象: ${avatar.firstImpression}`, + "", + `親密度レベル: ${relationshipStageOverride || stage}`, + ].join("\n") + : ""; + + const stageExpansion = avatar?.relationshipStages?.[stage] + ? `【アバター距離感(${stage})】\n${avatar.relationshipStages[stage]}` + : RELATIONSHIP_PROMPTS[stage].trim(); + + const backgroundFlavor = backgroundKey + ? BACKGROUND_BEHAVIOR_PROMPTS[backgroundKey] + : ""; + + return [ + ...BASE_PROMPT_HEADER, + personaBlock, + backgroundFlavor, + "【共通ルール】", + COMMON_RULES, + stageExpansion, + ] + .filter(Boolean) + .join("\n"); }; /** @@ -158,210 +158,210 @@ const buildSystemPrompt = ( * @returns AIによる応答テキスト */ export type EmotionLabel = - | "neutral" - | "happy" - | "sad" - | "surprised" - | "angry" - | "bashful"; + | "neutral" + | "happy" + | "sad" + | "surprised" + | "angry" + | "bashful"; function inferEmotionFromConversation( - history: ConversationContext["messages"], - responseText: string + history: ConversationContext["messages"], + responseText: string, ): EmotionLabel { - // 直近ユーザー発話とAI応答を対象に軽量ルールベースで判定(将来的にLLM分類へ置換可能) - const lastUser = - history - .slice() - .reverse() - .find((m) => m.role === "user") - ?.content.toLowerCase() ?? ""; - const resp = responseText.toLowerCase(); - - const text = `${lastUser}\n${resp}`; - - // キーワード辞書 - const contains = (re: RegExp) => re.test(text); - - // bashful: 恥ずかし/照れ、褒められた/好き など - if (contains(/恥ずか|照れ|てれる|褒め|好き|ドキドキ|照れて|赤面/)) { - return "bashful"; - } - // angry: 否定/対立/罵倒/苛立ちを優先検出(surprisedやhappyよりも優先) - if ( - contains( - /むか|ムカ|怒|ふざけ(んな)?|いやだ|やだ|無理|ばか|バカ|くそ|クソ|あほ|アホ|ごみ|ゴミ|最悪|やめろ|は[??]|違うだろ|許せない|イライラ|腹立|もういい|くだらない/ - ) - ) { - return "angry"; - } - // happy: うれしい/楽しい/よかった/ありがとう/笑 - if (contains(/うれし|楽しい|よかった|ありがと|笑|嬉し|楽しかった/)) { - return "happy"; - } - // surprised: ほんと|マジ|えっ|えー|すご|びっくり|!?| - if (contains(/ほんと|本当|まじ|マジ|えっ|えー|すご|びっくり|!\?|\?!|!?/)) { - return "surprised"; - } - // sad: かなしい|悲し|つら|泣|しんど|もうだめ - if (contains(/かなしい|悲し|つら|辛|泣|しんど|もうだめ|落ち込/)) { - return "sad"; - } - return "neutral"; + // 直近ユーザー発話とAI応答を対象に軽量ルールベースで判定(将来的にLLM分類へ置換可能) + const lastUser = + history + .slice() + .reverse() + .find((m) => m.role === "user") + ?.content.toLowerCase() ?? ""; + const resp = responseText.toLowerCase(); + + const text = `${lastUser}\n${resp}`; + + // キーワード辞書 + const contains = (re: RegExp) => re.test(text); + + // bashful: 恥ずかし/照れ、褒められた/好き など + if (contains(/恥ずか|照れ|てれる|褒め|好き|ドキドキ|照れて|赤面/)) { + return "bashful"; + } + // angry: 否定/対立/罵倒/苛立ちを優先検出(surprisedやhappyよりも優先) + if ( + contains( + /むか|ムカ|怒|ふざけ(んな)?|いやだ|やだ|無理|ばか|バカ|くそ|クソ|あほ|アホ|ごみ|ゴミ|最悪|やめろ|は[??]|違うだろ|許せない|イライラ|腹立|もういい|くだらない/, + ) + ) { + return "angry"; + } + // happy: うれしい/楽しい/よかった/ありがとう/笑 + if (contains(/うれし|楽しい|よかった|ありがと|笑|嬉し|楽しかった/)) { + return "happy"; + } + // surprised: ほんと|マジ|えっ|えー|すご|びっくり|!?| + if (contains(/ほんと|本当|まじ|マジ|えっ|えー|すご|びっくり|!\?|\?!|!?/)) { + return "surprised"; + } + // sad: かなしい|悲し|つら|泣|しんど|もうだめ + if (contains(/かなしい|悲し|つら|辛|泣|しんど|もうだめ|落ち込/)) { + return "sad"; + } + return "neutral"; } export async function generateConversationResponse( - apiKey: string, - context: ConversationContext, - options?: { - temperature?: number; - maxTokens?: number; - modelName?: string; - } + apiKey: string, + context: ConversationContext, + options?: { + temperature?: number; + maxTokens?: number; + modelName?: string; + }, ): Promise<{ text: string; emotion: EmotionLabel }> { - const client = getGeminiClient(apiKey) as GoogleGenAI; - const modelName = options?.modelName || "gemini-2.5-flash-lite"; - - // ユーザーメッセージが対応していない言語の場合、自然に促す - const lastUserMessage = context.messages[context.messages.length - 1]; - if ( - lastUserMessage && - lastUserMessage.role === "user" && - lastUserMessage.content === "[UNSUPPORTED_LANGUAGE]" - ) { - const naturalResponses = [ - "ごめん、ちょっと聞き取れなかった。何語?", - "あれ、何語? ", - "ごめんね、わたし日本語と英語しかわからないんだ", - "ん?何の話?", - ]; - const text = - naturalResponses[Math.floor(Math.random() * naturalResponses.length)]; - return { text, emotion: "neutral" }; - } - - // relationshipStage を使ったシステムプロンプト設定 - const relationshipStage: RelationshipStage = - context.relationshipStage ?? "shy"; - let systemPrompt = buildSystemPrompt( - relationshipStage, - context.avatarConfig, - context.relationshipStage, - context.backgroundKey - ); - - // context.systemPrompt が明示的に与えられている場合はそれを優先 - if (context.systemPrompt) { - systemPrompt = context.systemPrompt; - } - - // 直近Nターンを強調(ユーザー発話に重み付け) - const N = 6; - const recent = context.messages.slice(-N); - const recentUserTexts = recent - .filter((m) => m.role === "user") - .map((m) => m.content); - const recentAssistantTexts = recent - .filter((m) => m.role === "assistant") - .map((m) => m.content); - - // マルチモーダル(表情/視線)サマリ - const g = context.gestureSummary; - const gestureInfo = g - ? `表情視線メトリクス:\n- 笑顔検出回数: ${ - g.smilingSamples - }\n- 笑顔強度平均: ${g.smileIntensityAvg.toFixed( - 2 - )}\n- 視線安定スコア(0-1): ${g.gazeScoreAvg.toFixed(2)}\n- 視線上: ${ - g.gazeUpSamples - }, 視線下: ${g.gazeDownSamples}, 注視: ${g.lookingSamples}` - : "表情/視線メトリクス: データなし"; - - // LLMへのシステム誘導: JSONで {text, emotion} - const emotionBiasLine = context.avatarConfig?.fallbackEmotionBias - ? `参考バイアス: ${Object.entries(context.avatarConfig.fallbackEmotionBias) - .map(([k, v]) => `${k}:${v}`) - .join(" ")}` - : ""; - const instruction = `以下の入力をもとに、会話の次の応答を日本語で1-3文で生成し、同時に感情ラベルを付与してください。必ず次のJSON形式のみを出力してください(前後に解説やマークダウンを付けない):\n{\n "text": string,\n "emotion": one of ["neutral", "happy", "sad", "surprised", "angry", "bashful"]\n}\n\n応答ポリシー(重要度順):\n1) まず最初に、【直近のユーザー発話】の問い/意図に対して直接の回答を1-2文で示す(古い話題に引きずられない)\n2) 余力があれば、会話継続のための短い問いかけを「1つだけ」添える(不要なら省略可)\n3) {speakingStyle} と 親密度 {relationshipStage} に厳密に従い、自然な日本語で\n\n付与方針(感情ラベル):\n- 【最重視】直近のユーザー発話の内容\n- 【重視】視線(gaze)傾向(補助)\n- 【弱め】表情(smile)(補助)\n- 対立・否定・苛立ち・罵倒(例: 「ふざけんな」「最悪」「違うだろ」「やめろ」「は?」など)が含まれる場合は、sad/surprisedよりangryを優先\n- 連続ターンでの急変は避ける\n${emotionBiasLine}`; - - const compiledInput = `【直近のユーザー発話】\n${ - recentUserTexts.slice(-1)[0] ?? "(なし)" - }\n\n【最近のユーザー発話(新しい順)】\n${recentUserTexts - .slice() - .reverse() - .map((t, i) => `(${i + 1}) ${t}`) - .join("\n")}\n\n【最近のAI発話(新しい順)】\n${recentAssistantTexts - .slice() - .reverse() - .map((t, i) => `(${i + 1}) ${t}`) - .join("\n")}\n\n${gestureInfo}`; - - // LLMへは role/user と systemInstruction を併用 - // LLMには「直近のユーザー発話」を明示的に入力し、会話サマリはsystemInstructionに含める - const contents = [ - { - role: "user" as const, - parts: [{ text: recentUserTexts.slice(-1)[0] ?? "" }], - }, - ]; - - // 最後のユーザーメッセージで応答を生成 - const lastMessage = contents[contents.length - 1]; - if (!lastMessage || lastMessage.role !== "user") { - throw new Error("Last message must be from user"); - } - - // 会話履歴とシステムプロンプトを含めてリクエストを構築 - const result = await client.models.generateContent({ - model: modelName, - contents: contents, - config: { - temperature: options?.temperature ?? 0.9, - maxOutputTokens: options?.maxTokens ?? 150, - systemInstruction: `${systemPrompt}\n\n${instruction}\n\n【会話サマリ】\n${compiledInput}`, - }, - }); - - const responseText = result.text; - if (!responseText) { - throw new Error("No response text generated"); - } - // JSON抽出・パース - let text = ""; - let emotion: EmotionLabel = "neutral"; - try { - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[0]) as { - text?: string; - emotion?: EmotionLabel; - }; - text = (parsed.text ?? "").toString(); - emotion = (parsed.emotion ?? "neutral") as EmotionLabel; - } - } catch { - // ignore, fallback below - } - if (!text) { - // 万一JSONで来なかった場合は、全文をtextとして扱い、旧ルールでemotionを補完 - text = responseText.trim(); - emotion = inferEmotionFromConversation(context.messages, text); - } - // 追加バイアス: ユーザーの直近発話に強い否定/対立ワードや強い感嘆がある場合は angry を優先 - try { - const lastTwoUserTexts = recentUserTexts.slice(-2).join("\n"); - const lowered = lastTwoUserTexts.toLowerCase(); - const angryHint = - /(むか|怒|ふざけ(んな)?|いやだ|やだ|無理|ばか|くそ|あほ|ごみ|最悪|やめろ|は[??]|違うだろ|許せない|いらいら|腹立|もういい|くだらない)/i; - const exclamations = lastTwoUserTexts.match(/[!!]/g)?.length ?? 0; - if (emotion !== "angry" && (angryHint.test(lowered) || exclamations >= 3)) { - emotion = "angry"; - } - } catch { - // 解析失敗時は素通し - } - return { text, emotion }; + const client = getGeminiClient(apiKey) as GoogleGenAI; + const modelName = options?.modelName || "gemini-2.5-flash-lite"; + + // ユーザーメッセージが対応していない言語の場合、自然に促す + const lastUserMessage = context.messages[context.messages.length - 1]; + if ( + lastUserMessage && + lastUserMessage.role === "user" && + lastUserMessage.content === "[UNSUPPORTED_LANGUAGE]" + ) { + const naturalResponses = [ + "ごめん、ちょっと聞き取れなかった。何語?", + "あれ、何語? ", + "ごめんね、わたし日本語と英語しかわからないんだ", + "ん?何の話?", + ]; + const text = + naturalResponses[Math.floor(Math.random() * naturalResponses.length)]; + return { text, emotion: "neutral" }; + } + + // relationshipStage を使ったシステムプロンプト設定 + const relationshipStage: RelationshipStage = + context.relationshipStage ?? "shy"; + let systemPrompt = buildSystemPrompt( + relationshipStage, + context.avatarConfig, + context.relationshipStage, + context.backgroundKey, + ); + + // context.systemPrompt が明示的に与えられている場合はそれを優先 + if (context.systemPrompt) { + systemPrompt = context.systemPrompt; + } + + // 直近Nターンを強調(ユーザー発話に重み付け) + const N = 6; + const recent = context.messages.slice(-N); + const recentUserTexts = recent + .filter((m) => m.role === "user") + .map((m) => m.content); + const recentAssistantTexts = recent + .filter((m) => m.role === "assistant") + .map((m) => m.content); + + // マルチモーダル(表情/視線)サマリ + const g = context.gestureSummary; + const gestureInfo = g + ? `表情視線メトリクス:\n- 笑顔検出回数: ${ + g.smilingSamples + }\n- 笑顔強度平均: ${g.smileIntensityAvg.toFixed( + 2, + )}\n- 視線安定スコア(0-1): ${g.gazeScoreAvg.toFixed(2)}\n- 視線上: ${ + g.gazeUpSamples + }, 視線下: ${g.gazeDownSamples}, 注視: ${g.lookingSamples}` + : "表情/視線メトリクス: データなし"; + + // LLMへのシステム誘導: JSONで {text, emotion} + const emotionBiasLine = context.avatarConfig?.fallbackEmotionBias + ? `参考バイアス: ${Object.entries(context.avatarConfig.fallbackEmotionBias) + .map(([k, v]) => `${k}:${v}`) + .join(" ")}` + : ""; + const instruction = `以下の入力をもとに、会話の次の応答を日本語で1-3文で生成し、同時に感情ラベルを付与してください。必ず次のJSON形式のみを出力してください(前後に解説やマークダウンを付けない):\n{\n "text": string,\n "emotion": one of ["neutral", "happy", "sad", "surprised", "angry", "bashful"]\n}\n\n応答ポリシー(重要度順):\n1) まず最初に、【直近のユーザー発話】の問い/意図に対して直接の回答を1-2文で示す(古い話題に引きずられない)\n2) 余力があれば、会話継続のための短い問いかけを「1つだけ」添える(不要なら省略可)\n3) {speakingStyle} と 親密度 {relationshipStage} に厳密に従い、自然な日本語で\n\n付与方針(感情ラベル):\n- 【最重視】直近のユーザー発話の内容\n- 【重視】視線(gaze)傾向(補助)\n- 【弱め】表情(smile)(補助)\n- 対立・否定・苛立ち・罵倒(例: 「ふざけんな」「最悪」「違うだろ」「やめろ」「は?」など)が含まれる場合は、sad/surprisedよりangryを優先\n- 連続ターンでの急変は避ける\n${emotionBiasLine}`; + + const compiledInput = `【直近のユーザー発話】\n${ + recentUserTexts.slice(-1)[0] ?? "(なし)" + }\n\n【最近のユーザー発話(新しい順)】\n${recentUserTexts + .slice() + .reverse() + .map((t, i) => `(${i + 1}) ${t}`) + .join("\n")}\n\n【最近のAI発話(新しい順)】\n${recentAssistantTexts + .slice() + .reverse() + .map((t, i) => `(${i + 1}) ${t}`) + .join("\n")}\n\n${gestureInfo}`; + + // LLMへは role/user と systemInstruction を併用 + // LLMには「直近のユーザー発話」を明示的に入力し、会話サマリはsystemInstructionに含める + const contents = [ + { + role: "user" as const, + parts: [{ text: recentUserTexts.slice(-1)[0] ?? "" }], + }, + ]; + + // 最後のユーザーメッセージで応答を生成 + const lastMessage = contents[contents.length - 1]; + if (!lastMessage || lastMessage.role !== "user") { + throw new Error("Last message must be from user"); + } + + // 会話履歴とシステムプロンプトを含めてリクエストを構築 + const result = await client.models.generateContent({ + model: modelName, + contents: contents, + config: { + temperature: options?.temperature ?? 0.9, + maxOutputTokens: options?.maxTokens ?? 150, + systemInstruction: `${systemPrompt}\n\n${instruction}\n\n【会話サマリ】\n${compiledInput}`, + }, + }); + + const responseText = result.text; + if (!responseText) { + throw new Error("No response text generated"); + } + // JSON抽出・パース + let text = ""; + let emotion: EmotionLabel = "neutral"; + try { + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) as { + text?: string; + emotion?: EmotionLabel; + }; + text = (parsed.text ?? "").toString(); + emotion = (parsed.emotion ?? "neutral") as EmotionLabel; + } + } catch { + // ignore, fallback below + } + if (!text) { + // 万一JSONで来なかった場合は、全文をtextとして扱い、旧ルールでemotionを補完 + text = responseText.trim(); + emotion = inferEmotionFromConversation(context.messages, text); + } + // 追加バイアス: ユーザーの直近発話に強い否定/対立ワードや強い感嘆がある場合は angry を優先 + try { + const lastTwoUserTexts = recentUserTexts.slice(-2).join("\n"); + const lowered = lastTwoUserTexts.toLowerCase(); + const angryHint = + /(むか|怒|ふざけ(んな)?|いやだ|やだ|無理|ばか|くそ|あほ|ごみ|最悪|やめろ|は[??]|違うだろ|許せない|いらいら|腹立|もういい|くだらない)/i; + const exclamations = lastTwoUserTexts.match(/[!!]/g)?.length ?? 0; + if (emotion !== "angry" && (angryHint.test(lowered) || exclamations >= 3)) { + emotion = "angry"; + } + } catch { + // 解析失敗時は素通し + } + return { text, emotion }; } /** @@ -371,342 +371,342 @@ export async function generateConversationResponse( * @returns フィードバック(良かった点と改善点) */ export interface GestureSummary { - totalSamples: number; - smilingSamples: number; - smileIntensityAvg: number; - smileIntensityMax: number; - gazeScoreAvg: number; - lookingSamples: number; - gazeUpSamples: number; - gazeDownSamples: number; + totalSamples: number; + smilingSamples: number; + smileIntensityAvg: number; + smileIntensityMax: number; + gazeScoreAvg: number; + lookingSamples: number; + gazeUpSamples: number; + gazeDownSamples: number; } export interface VoiceMetrics { - volumeScore: number; - volumeLevel: "quiet" | "balanced" | "energetic"; - volumeComment: string; - articulationScore: number; - articulationComment: string; - speedScore: number; - speedLevel: "slow" | "ideal" | "fast"; - speedComment: string; - fillerWords: { - totalCount: number; - breakdown: { word: string; count: number }[]; - }; - tremblingDetected: boolean; - tremblingComment: string; - summary: string; + volumeScore: number; + volumeLevel: "quiet" | "balanced" | "energetic"; + volumeComment: string; + articulationScore: number; + articulationComment: string; + speedScore: number; + speedLevel: "slow" | "ideal" | "fast"; + speedComment: string; + fillerWords: { + totalCount: number; + breakdown: { word: string; count: number }[]; + }; + tremblingDetected: boolean; + tremblingComment: string; + summary: string; } const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max); + Math.min(Math.max(value, min), max); const FILLER_PATTERNS: { word: string; pattern: RegExp }[] = [ - { word: "えー", pattern: /えー/gi }, - { word: "えぇ", pattern: /えぇ/gi }, - { word: "えっと", pattern: /えっと/gi }, - { word: "あの", pattern: /あの/gi }, - { word: "その", pattern: /そのさ|そのね/gi }, - { word: "うーん", pattern: /うーん/gi }, - { word: "なんか", pattern: /なんか/gi }, - { word: "まあ", pattern: /まあ/gi }, - { word: "um", pattern: /\bum\b/gi }, - { word: "uh", pattern: /\buh\b/gi }, + { word: "えー", pattern: /えー/gi }, + { word: "えぇ", pattern: /えぇ/gi }, + { word: "えっと", pattern: /えっと/gi }, + { word: "あの", pattern: /あの/gi }, + { word: "その", pattern: /そのさ|そのね/gi }, + { word: "うーん", pattern: /うーん/gi }, + { word: "なんか", pattern: /なんか/gi }, + { word: "まあ", pattern: /まあ/gi }, + { word: "um", pattern: /\bum\b/gi }, + { word: "uh", pattern: /\buh\b/gi }, ]; function calculateVoiceMetrics(messages: ConversationMessage[]): VoiceMetrics { - const userMessages = messages.filter((msg) => msg.role === "user"); - - if (userMessages.length === 0) { - return { - volumeScore: 0, - volumeLevel: "quiet", - volumeComment: - "ユーザーの音声データが不足しているため評価できませんでした。", - articulationScore: 0, - articulationComment: - "ユーザーの音声データが不足しているため評価できませんでした。", - speedScore: 0, - speedLevel: "slow", - speedComment: - "ユーザーの音声データが不足しているため評価できませんでした。", - fillerWords: { totalCount: 0, breakdown: [] }, - tremblingDetected: false, - tremblingComment: "音声データが不足しているため判断できませんでした。", - summary: - "音声データが不足しているため詳細なフィードバックを生成できませんでした。", - }; - } - - const trimmedMessages = userMessages.map((msg) => msg.content.trim()); - const charCounts = trimmedMessages.map( - (content) => content.replace(/\s+/g, "").length - ); - const avgCharCount = - charCounts.reduce((sum, value) => sum + value, 0) / charCounts.length || 0; - const exclamationCount = trimmedMessages.reduce( - (sum, content) => sum + (content.match(/[!!]/g)?.length ?? 0), - 0 - ); - - const fillerBreakdown = FILLER_PATTERNS.map(({ word, pattern }) => { - const count = trimmedMessages.reduce( - (sum, content) => sum + (content.match(pattern)?.length ?? 0), - 0 - ); - return { word, count }; - }).filter(({ count }) => count > 0); - - const totalFillerCount = fillerBreakdown.reduce( - (sum, item) => sum + item.count, - 0 - ); - - const ellipsisCount = trimmedMessages.reduce( - (sum, content) => sum + (content.match(/…|\.{3,}/g)?.length ?? 0), - 0 - ); - - const repeatedKanaCount = trimmedMessages.reduce( - (sum, content) => - sum + (content.match(/([ぁ-んァ-ン])\1{2,}/g)?.length ?? 0), - 0 - ); - - let volumeScore = 60; - if (avgCharCount > 35) { - volumeScore += 12; - } else if (avgCharCount < 18) { - volumeScore -= 12; - } - volumeScore += Math.min(10, exclamationCount * 2); - volumeScore -= Math.min(15, totalFillerCount * 1.5); - volumeScore = clamp(Math.round(volumeScore), 0, 100); - - let volumeLevel: VoiceMetrics["volumeLevel"] = "balanced"; - let volumeComment = "声量は適度で聞き取りやすい印象です。"; - if (volumeScore >= 75) { - volumeLevel = "energetic"; - volumeComment = "しっかり声が出ていて、明るい印象を与えています。"; - } else if (volumeScore <= 50) { - volumeLevel = "quiet"; - volumeComment = - "やや声が小さく聞こえる可能性があります。もう少しハッキリ発声しましょう。"; - } - - const articulationBase = 84; - const fillerPenalty = clamp(totalFillerCount * 5.5, 0, 33); - const repeatPenalty = clamp(repeatedKanaCount * 6, 0, 24); - const articulationScore = clamp( - Math.round(articulationBase - fillerPenalty - repeatPenalty), - 0, - 100 - ); - let articulationComment = "滑舌は良好で、明瞭に話せています。"; - if (articulationScore >= 88) { - articulationComment = - "滑舌は非常にクリアで、言葉がはっきり伝わっています。"; - } else if (articulationScore <= 62) { - articulationComment = - "フィラーや言い直しが目立ちました。語尾まで意識して発音すると改善します。"; - } - - const messagesWithTimestamp = userMessages - .map((msg) => { - if (!msg.createdAt) return null; - const ts = - typeof msg.createdAt === "string" - ? new Date(msg.createdAt).getTime() - : msg.createdAt.getTime(); - if (Number.isNaN(ts)) return null; - return { timestamp: ts, content: msg.content }; - }) - .filter( - (item): item is { timestamp: number; content: string } => item !== null - ); - - const paceSamples: number[] = []; - for (let i = 1; i < messagesWithTimestamp.length; i += 1) { - const prev = messagesWithTimestamp[i - 1]; - const cur = messagesWithTimestamp[i]; - const deltaSeconds = (cur.timestamp - prev.timestamp) / 1000; - if (deltaSeconds <= 1) continue; - const characters = cur.content.replace(/\s+/g, "").length; - const pace = characters / deltaSeconds; - if (Number.isFinite(pace) && pace > 0) { - paceSamples.push(pace); - } - } - - const averagePace = - paceSamples.reduce((sum, value) => sum + value, 0) / - (paceSamples.length || 1) || 0; - - let speedScore = 72; - if (averagePace === 0) { - speedScore = 65; - } else if (averagePace > 5.5) { - speedScore -= Math.min(22, (averagePace - 5.5) * 11); - } else if (averagePace < 2.5) { - speedScore -= Math.min(22, (2.5 - averagePace) * 11); - } else { - speedScore += 8; - } - speedScore = clamp(Math.round(speedScore), 0, 100); - - let speedLevel: VoiceMetrics["speedLevel"] = "ideal"; - let speedComment = "話すスピードはちょうど良く、聞き取りやすいテンポです。"; - if (averagePace === 0) { - speedLevel = "slow"; - speedComment = - "話速の推定に十分なデータが無かったため、普段通りのテンポを意識してみましょう。"; - } else if (speedScore <= 55) { - if (averagePace < 2.5) { - speedLevel = "slow"; - speedComment = - "落ち着いた話し方ですが、もう少しテンポを上げると会話が弾みやすくなります。"; - } else { - speedLevel = "fast"; - speedComment = - "やや早口ぎみでした。語尾まで丁寧に言い切る意識を持つと伝わりやすさが上がります。"; - } - } else if (speedScore >= 80) { - speedLevel = "ideal"; - speedComment = - "テンポが安定していて、勢いと聞き取りやすさのバランスが取れています。"; - } - - const tremblingKeywordDetected = trimmedMessages.some((content) => - /震え|ふるえ|緊張|ガチガチ|ブルブル/.test(content) - ); - const highFillerDensity = - totalFillerCount > 0 && - totalFillerCount / userMessages.length >= 2 && - ellipsisCount >= userMessages.length; - const tremblingDetected = tremblingKeywordDetected || highFillerDensity; - - let tremblingComment = - "声の震えは特に検知されませんでした。落ち着いた発声ができています。"; - if (tremblingDetected) { - tremblingComment = - "緊張に由来する言い直しやフィラーが目立ちました。深呼吸をしてから話すと安定します。"; - } - - const summaryParts: string[] = []; - if (volumeLevel === "energetic") { - summaryParts.push("声量は十分で、自信のある印象です。"); - } else if (volumeLevel === "quiet") { - summaryParts.push("もう少し声量を上げると伝わりやすくなります。"); - } else { - summaryParts.push("声量はちょうど良く、落ち着いて話せています。"); - } - - if (articulationScore >= 80) { - summaryParts.push("滑舌は明瞭で言葉がクリアに届いています。"); - } else { - summaryParts.push( - "滑舌にやや課題があるため、フィラーを減らす練習をしてみましょう。" - ); - } - - if (speedLevel === "ideal") { - summaryParts.push("話速も適度で、聞き取りやすいテンポです。"); - } else if (speedLevel === "slow") { - summaryParts.push("テンポを少し上げると会話がよりスムーズになります。"); - } else { - summaryParts.push( - "やや早口なので、要点ごとに区切る意識を持つと安心感が出ます。" - ); - } - - if (totalFillerCount > 0) { - summaryParts.push( - `フィラーは合計${totalFillerCount}回でした。減らせるとさらに自然になります。` - ); - } - - return { - volumeScore, - volumeLevel, - volumeComment, - articulationScore, - articulationComment, - speedScore, - speedLevel, - speedComment, - fillerWords: { - totalCount: totalFillerCount, - breakdown: fillerBreakdown.sort((a, b) => b.count - a.count), - }, - tremblingDetected, - tremblingComment, - summary: summaryParts.join(" "), - }; + const userMessages = messages.filter((msg) => msg.role === "user"); + + if (userMessages.length === 0) { + return { + volumeScore: 0, + volumeLevel: "quiet", + volumeComment: + "ユーザーの音声データが不足しているため評価できませんでした。", + articulationScore: 0, + articulationComment: + "ユーザーの音声データが不足しているため評価できませんでした。", + speedScore: 0, + speedLevel: "slow", + speedComment: + "ユーザーの音声データが不足しているため評価できませんでした。", + fillerWords: { totalCount: 0, breakdown: [] }, + tremblingDetected: false, + tremblingComment: "音声データが不足しているため判断できませんでした。", + summary: + "音声データが不足しているため詳細なフィードバックを生成できませんでした。", + }; + } + + const trimmedMessages = userMessages.map((msg) => msg.content.trim()); + const charCounts = trimmedMessages.map( + (content) => content.replace(/\s+/g, "").length, + ); + const avgCharCount = + charCounts.reduce((sum, value) => sum + value, 0) / charCounts.length || 0; + const exclamationCount = trimmedMessages.reduce( + (sum, content) => sum + (content.match(/[!!]/g)?.length ?? 0), + 0, + ); + + const fillerBreakdown = FILLER_PATTERNS.map(({ word, pattern }) => { + const count = trimmedMessages.reduce( + (sum, content) => sum + (content.match(pattern)?.length ?? 0), + 0, + ); + return { word, count }; + }).filter(({ count }) => count > 0); + + const totalFillerCount = fillerBreakdown.reduce( + (sum, item) => sum + item.count, + 0, + ); + + const ellipsisCount = trimmedMessages.reduce( + (sum, content) => sum + (content.match(/…|\.{3,}/g)?.length ?? 0), + 0, + ); + + const repeatedKanaCount = trimmedMessages.reduce( + (sum, content) => + sum + (content.match(/([ぁ-んァ-ン])\1{2,}/g)?.length ?? 0), + 0, + ); + + let volumeScore = 60; + if (avgCharCount > 35) { + volumeScore += 12; + } else if (avgCharCount < 18) { + volumeScore -= 12; + } + volumeScore += Math.min(10, exclamationCount * 2); + volumeScore -= Math.min(15, totalFillerCount * 1.5); + volumeScore = clamp(Math.round(volumeScore), 0, 100); + + let volumeLevel: VoiceMetrics["volumeLevel"] = "balanced"; + let volumeComment = "声量は適度で聞き取りやすい印象です。"; + if (volumeScore >= 75) { + volumeLevel = "energetic"; + volumeComment = "しっかり声が出ていて、明るい印象を与えています。"; + } else if (volumeScore <= 50) { + volumeLevel = "quiet"; + volumeComment = + "やや声が小さく聞こえる可能性があります。もう少しハッキリ発声しましょう。"; + } + + const articulationBase = 84; + const fillerPenalty = clamp(totalFillerCount * 5.5, 0, 33); + const repeatPenalty = clamp(repeatedKanaCount * 6, 0, 24); + const articulationScore = clamp( + Math.round(articulationBase - fillerPenalty - repeatPenalty), + 0, + 100, + ); + let articulationComment = "滑舌は良好で、明瞭に話せています。"; + if (articulationScore >= 88) { + articulationComment = + "滑舌は非常にクリアで、言葉がはっきり伝わっています。"; + } else if (articulationScore <= 62) { + articulationComment = + "フィラーや言い直しが目立ちました。語尾まで意識して発音すると改善します。"; + } + + const messagesWithTimestamp = userMessages + .map((msg) => { + if (!msg.createdAt) return null; + const ts = + typeof msg.createdAt === "string" + ? new Date(msg.createdAt).getTime() + : msg.createdAt.getTime(); + if (Number.isNaN(ts)) return null; + return { timestamp: ts, content: msg.content }; + }) + .filter( + (item): item is { timestamp: number; content: string } => item !== null, + ); + + const paceSamples: number[] = []; + for (let i = 1; i < messagesWithTimestamp.length; i += 1) { + const prev = messagesWithTimestamp[i - 1]; + const cur = messagesWithTimestamp[i]; + const deltaSeconds = (cur.timestamp - prev.timestamp) / 1000; + if (deltaSeconds <= 1) continue; + const characters = cur.content.replace(/\s+/g, "").length; + const pace = characters / deltaSeconds; + if (Number.isFinite(pace) && pace > 0) { + paceSamples.push(pace); + } + } + + const averagePace = + paceSamples.reduce((sum, value) => sum + value, 0) / + (paceSamples.length || 1) || 0; + + let speedScore = 72; + if (averagePace === 0) { + speedScore = 65; + } else if (averagePace > 5.5) { + speedScore -= Math.min(22, (averagePace - 5.5) * 11); + } else if (averagePace < 2.5) { + speedScore -= Math.min(22, (2.5 - averagePace) * 11); + } else { + speedScore += 8; + } + speedScore = clamp(Math.round(speedScore), 0, 100); + + let speedLevel: VoiceMetrics["speedLevel"] = "ideal"; + let speedComment = "話すスピードはちょうど良く、聞き取りやすいテンポです。"; + if (averagePace === 0) { + speedLevel = "slow"; + speedComment = + "話速の推定に十分なデータが無かったため、普段通りのテンポを意識してみましょう。"; + } else if (speedScore <= 55) { + if (averagePace < 2.5) { + speedLevel = "slow"; + speedComment = + "落ち着いた話し方ですが、もう少しテンポを上げると会話が弾みやすくなります。"; + } else { + speedLevel = "fast"; + speedComment = + "やや早口ぎみでした。語尾まで丁寧に言い切る意識を持つと伝わりやすさが上がります。"; + } + } else if (speedScore >= 80) { + speedLevel = "ideal"; + speedComment = + "テンポが安定していて、勢いと聞き取りやすさのバランスが取れています。"; + } + + const tremblingKeywordDetected = trimmedMessages.some((content) => + /震え|ふるえ|緊張|ガチガチ|ブルブル/.test(content), + ); + const highFillerDensity = + totalFillerCount > 0 && + totalFillerCount / userMessages.length >= 2 && + ellipsisCount >= userMessages.length; + const tremblingDetected = tremblingKeywordDetected || highFillerDensity; + + let tremblingComment = + "声の震えは特に検知されませんでした。落ち着いた発声ができています。"; + if (tremblingDetected) { + tremblingComment = + "緊張に由来する言い直しやフィラーが目立ちました。深呼吸をしてから話すと安定します。"; + } + + const summaryParts: string[] = []; + if (volumeLevel === "energetic") { + summaryParts.push("声量は十分で、自信のある印象です。"); + } else if (volumeLevel === "quiet") { + summaryParts.push("もう少し声量を上げると伝わりやすくなります。"); + } else { + summaryParts.push("声量はちょうど良く、落ち着いて話せています。"); + } + + if (articulationScore >= 80) { + summaryParts.push("滑舌は明瞭で言葉がクリアに届いています。"); + } else { + summaryParts.push( + "滑舌にやや課題があるため、フィラーを減らす練習をしてみましょう。", + ); + } + + if (speedLevel === "ideal") { + summaryParts.push("話速も適度で、聞き取りやすいテンポです。"); + } else if (speedLevel === "slow") { + summaryParts.push("テンポを少し上げると会話がよりスムーズになります。"); + } else { + summaryParts.push( + "やや早口なので、要点ごとに区切る意識を持つと安心感が出ます。", + ); + } + + if (totalFillerCount > 0) { + summaryParts.push( + `フィラーは合計${totalFillerCount}回でした。減らせるとさらに自然になります。`, + ); + } + + return { + volumeScore, + volumeLevel, + volumeComment, + articulationScore, + articulationComment, + speedScore, + speedLevel, + speedComment, + fillerWords: { + totalCount: totalFillerCount, + breakdown: fillerBreakdown.sort((a, b) => b.count - a.count), + }, + tremblingDetected, + tremblingComment, + summary: summaryParts.join(" "), + }; } const BACKGROUND_CONTEXT: Record< - BackgroundKey, - { scenario: string; guidelines: string[] } + BackgroundKey, + { scenario: string; guidelines: string[] } > = { - library: { - scenario: - "入学式の校庭。初対面らしく丁寧に挨拶しつつ、学校生活や授業の話題で自然に距離を縮めよう。", - guidelines: [ - "初めましての挨拶を交わし、場の空気を和らげる", - "勉強や授業の話題を出して学校トークにつなげる", - "相手が答えやすいオープンな質問を投げる", - ], - }, - classroom: { - scenario: - "放課後の教室。AIはクラスメイト。授業や課題、サークル、週末の予定など身近な話題を話そう。", - guidelines: [ - "授業・課題・サークルなど身近な話題を出す", - "週末や放課後の軽い予定提案/質問をする", - "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", - ], - }, - xmas: { - scenario: - "最近の出来事やプレゼント、冬の予定など明るい話題で盛り上がりやすいです。いいムードを維持しよう。", - guidelines: [ - "明るく前向きなリアクションを返す", - "冬/クリスマス関連の話題(イルミ・プレゼント・予定)", - "軽い提案(観に行く/写真/カフェ など)", - ], - }, + library: { + scenario: + "入学式の校庭。初対面らしく丁寧に挨拶しつつ、学校生活や授業の話題で自然に距離を縮めよう。", + guidelines: [ + "初めましての挨拶を交わし、場の空気を和らげる", + "勉強や授業の話題を出して学校トークにつなげる", + "相手が答えやすいオープンな質問を投げる", + ], + }, + classroom: { + scenario: + "放課後の教室。AIはクラスメイト。授業や課題、サークル、週末の予定など身近な話題を話そう。", + guidelines: [ + "授業・課題・サークルなど身近な話題を出す", + "週末や放課後の軽い予定提案/質問をする", + "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", + ], + }, + xmas: { + scenario: + "最近の出来事やプレゼント、冬の予定など明るい話題で盛り上がりやすいです。いいムードを維持しよう。", + guidelines: [ + "明るく前向きなリアクションを返す", + "冬/クリスマス関連の話題(イルミ・プレゼント・予定)", + "軽い提案(観に行く/写真/カフェ など)", + ], + }, }; export async function generateConversationFeedback( - apiKey: string, - messages: ConversationMessage[], - gestureSummary?: GestureSummary, - backgroundKey?: BackgroundKey, - adviceCompletedIds?: string[] + apiKey: string, + messages: ConversationMessage[], + gestureSummary?: GestureSummary, + backgroundKey?: BackgroundKey, + adviceCompletedIds?: string[], ): Promise<{ - goodPoints: string; - improvementPoints: string; - overallScore: number | null; - conversationScore: number | null; - gestureScore: number | null; - voiceScore: number | null; - gestureGoodPoints?: string; - gestureImprovementPoints?: string; - voiceMetrics: VoiceMetrics; - adviceScoreAdded?: number; - adviceUnfulfilled?: string; - adviceFulfilledDetails?: { id: string; label: string; points: number }[]; + goodPoints: string; + improvementPoints: string; + overallScore: number | null; + conversationScore: number | null; + gestureScore: number | null; + voiceScore: number | null; + gestureGoodPoints?: string; + gestureImprovementPoints?: string; + voiceMetrics: VoiceMetrics; + adviceScoreAdded?: number; + adviceUnfulfilled?: string; + adviceFulfilledDetails?: { id: string; label: string; points: number }[]; }> { - const client = getGeminiClient(apiKey) as GoogleGenAI; + const client = getGeminiClient(apiKey) as GoogleGenAI; - // 会話履歴をテキストに変換 - const conversationText = messages - .map((msg) => `${msg.role === "user" ? "ユーザー" : "AI"}: ${msg.content}`) - .join("\n"); + // 会話履歴をテキストに変換 + const conversationText = messages + .map((msg) => `${msg.role === "user" ? "ユーザー" : "AI"}: ${msg.content}`) + .join("\n"); - const gestureInfo = gestureSummary - ? `仕草計測データ: + const gestureInfo = gestureSummary + ? `仕草計測データ: - 総サンプル数: ${gestureSummary.totalSamples} - 笑顔検出回数: ${gestureSummary.smilingSamples} - 笑顔強度平均: ${gestureSummary.smileIntensityAvg.toFixed(2)} @@ -715,11 +715,11 @@ export async function generateConversationFeedback( - 視線がターゲットを向いていた回数: ${gestureSummary.lookingSamples} - 視線が上方向だった回数: ${gestureSummary.gazeUpSamples} - 視線が下方向だった回数: ${gestureSummary.gazeDownSamples}` - : "仕草データはありません"; + : "仕草データはありません"; - const voiceMetrics = calculateVoiceMetrics(messages); + const voiceMetrics = calculateVoiceMetrics(messages); - const voiceInfo = `声の分析データ: + const voiceInfo = `声の分析データ: - ボリュームスコア(0-100): ${voiceMetrics.volumeScore} - 滑舌スコア(0-100): ${voiceMetrics.articulationScore} - 話速スコア(0-100): ${voiceMetrics.speedScore} @@ -727,17 +727,17 @@ export async function generateConversationFeedback( - 震え検知: ${voiceMetrics.tremblingDetected ? "あり" : "なし"} - サマリー: ${voiceMetrics.summary}`; - const backgroundBlock = backgroundKey - ? `\n【背景シチュエーション】\n${ - BACKGROUND_CONTEXT[backgroundKey].scenario - }\n\n【背景に応じた評価観点(加点対象)】\n- ${BACKGROUND_CONTEXT[ - backgroundKey - ].guidelines.join("\n- ")}` - : ""; + const backgroundBlock = backgroundKey + ? `\n【背景シチュエーション】\n${ + BACKGROUND_CONTEXT[backgroundKey].scenario + }\n\n【背景に応じた評価観点(加点対象)】\n- ${BACKGROUND_CONTEXT[ + backgroundKey + ].guidelines.join("\n- ")}` + : ""; - const prompt = `以下の会話と仕草・音声データ${ - backgroundKey ? "、背景シチュエーション" : "" - }を分析し、ユーザー(男子大学生)のコミュニケーションスキルについてフィードバックを提供してください。 + const prompt = `以下の会話と仕草・音声データ${ + backgroundKey ? "、背景シチュエーション" : "" + }を分析し、ユーザー(男子大学生)のコミュニケーションスキルについてフィードバックを提供してください。 【会話内容】 ${conversationText} @@ -780,9 +780,9 @@ ${backgroundBlock} 【背景適合に関する加点方針】 ${ - backgroundKey - ? `- 上記「背景に応じた評価観点」に該当する発話や行動が確認できた場合、会話スコア内で適切に加点する(無理に満点化はしない)\n- 背景に明確にそぐわない配慮不足(例: 入学式で場違いにふるまうなど)が続く場合は、会話スコア内で軽度の減点を検討する(他の観点も加味して総合的に判断)` - : "- 背景情報がない場合は通常の評価基準を適用する" + backgroundKey + ? `- 上記「背景に応じた評価観点」に該当する発話や行動が確認できた場合、会話スコア内で適切に加点する(無理に満点化はしない)\n- 背景に明確にそぐわない配慮不足(例: 入学式で場違いにふるまうなど)が続く場合は、会話スコア内で軽度の減点を検討する(他の観点も加味して総合的に判断)` + : "- 背景情報がない場合は通常の評価基準を適用する" } 【減点要素】 @@ -830,172 +830,172 @@ ${ - **良かった点と改善点は、それぞれ優先度の高いものから順に最大3個まで**に絞ってください - 改善点は、改善すべき優先度が高い順(影響度が大きい順)に並べてください`; - const result = await client.models.generateContent({ - model: "gemini-2.5-flash-lite", - contents: prompt, - }); - - const responseText = result.text; - if (!responseText) { - throw new Error("No response text generated for feedback"); - } - - // JSONを抽出してパース - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error("Failed to parse feedback response"); - } - - const feedback = JSON.parse(jsonMatch[0]); - - const conversationFeedback = feedback.conversation ?? {}; - const gestureFeedback = feedback.gestures ?? {}; - const voiceFeedback = feedback.voice ?? {}; - - const normalizeScore = (value: unknown, max: number): number | null => { - if (value === null || value === undefined) { - return null; - } - const parsed = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value.replace(/[^\d.-]/g, "")) - : Number.NaN; - - if (Number.isNaN(parsed) || !Number.isFinite(parsed)) { - return null; - } - - return clamp(Math.round(parsed), 0, max); - }; - - const conversationScore = normalizeScore(conversationFeedback.score, 40); - const gestureScore = normalizeScore(gestureFeedback.score, 50); - const voiceScore = normalizeScore(voiceFeedback.score, 10); - - const hasAnyScore = - conversationScore !== null || gestureScore !== null || voiceScore !== null; - - let overallScore = hasAnyScore - ? clamp( - (conversationScore ?? 0) + (gestureScore ?? 0) + (voiceScore ?? 0), - 0, - 100 - ) - : null; - - const toText = (value: unknown): string => { - if (Array.isArray(value)) { - return value.join("\n"); - } - if (typeof value === "string") { - return value; - } - return ""; - }; - - // ---- アドバイス加点・未達算出(DB保存はせずレスポンスへ含める) ---- - let adviceScoreAdded: number | undefined; - let adviceUnfulfilled: string | undefined; - let adviceFulfilledDetails: - | { id: string; label: string; points: number }[] - | undefined; - - let improvementPointsStr = toText( - feedback.conversation?.improvementPoints ?? feedback.improvementPoints - ); - const backgroundAdviceMap: Record< - BackgroundKey, - { id: string; label: string }[] - > = { - library: [ - { id: "library_visit_question", label: "初めましての挨拶をする" }, - { id: "study_topic", label: "勉強や授業の話題を出す" }, - { - id: "open_question", - label: "女の子が答えやすいオープンな質問を投げる", - }, - ], - classroom: [ - { id: "class_topic", label: "授業・課題・サークルなど身近な話題を出す" }, - { id: "weekend_plan", label: "週末や放課後の軽い予定提案/質問をする" }, - { - id: "follow_up_question", - label: "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", - }, - ], - xmas: [ - { id: "xmas_topic", label: "冬/クリスマス関連話題" }, - { id: "positive_mood", label: "前向きリアクション" }, - { id: "suggest_plan", label: "軽い提案(観に行く/写真/カフェ等)" }, - ], - }; - if ( - backgroundKey && - adviceCompletedIds && - Array.isArray(adviceCompletedIds) - ) { - const adviceIdsCompleted = new Set(adviceCompletedIds); - const weightPer = 3; - const maxBonus = 8; - const candidates = backgroundAdviceMap[backgroundKey] ?? []; - const completed = candidates.filter((c) => adviceIdsCompleted.has(c.id)); - - let remaining = maxBonus; - adviceFulfilledDetails = []; - for (const item of completed) { - if (remaining <= 0) break; - const allocate = Math.min(weightPer, remaining); - adviceFulfilledDetails.push({ - id: item.id, - label: item.label, - points: allocate, - }); - remaining -= allocate; - } - - const bonus = adviceFulfilledDetails.reduce((sum, i) => sum + i.points, 0); - adviceScoreAdded = Math.min(bonus, maxBonus); - - const unfulfilled = candidates.filter((c) => !adviceIdsCompleted.has(c.id)); - if (unfulfilled.length) { - adviceUnfulfilled = unfulfilled.map((u) => u.label).join("\n"); - const lines = improvementPointsStr.split(/\r?\n/).filter((l) => l.trim()); - if (lines.length < 3) { - lines.push( - `背景適合: 以下を盛り込むとさらに良い→ ${unfulfilled - .map((u) => u.label) - .slice(0, 2) - .join("、")}` - ); - improvementPointsStr = lines.join("\n"); - } - } - - if (hasAnyScore && bonus > 0) { - const baseSum = - (conversationScore ?? 0) + (gestureScore ?? 0) + (voiceScore ?? 0); - overallScore = clamp(baseSum + bonus, 0, 100); // 総合=会話+仕草+声+アドバイス - } - } - - return { - goodPoints: toText(conversationFeedback.goodPoints ?? feedback.goodPoints), - improvementPoints: improvementPointsStr, - overallScore, - conversationScore, - gestureScore, - voiceScore, - gestureGoodPoints: toText( - gestureFeedback.goodPoints ?? feedback.gestureGoodPoints - ), - gestureImprovementPoints: toText( - gestureFeedback.improvementPoints ?? feedback.gestureImprovementPoints - ), - voiceMetrics, - adviceScoreAdded, - adviceUnfulfilled, - adviceFulfilledDetails, - }; + const result = await client.models.generateContent({ + model: "gemini-2.5-flash-lite", + contents: prompt, + }); + + const responseText = result.text; + if (!responseText) { + throw new Error("No response text generated for feedback"); + } + + // JSONを抽出してパース + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("Failed to parse feedback response"); + } + + const feedback = JSON.parse(jsonMatch[0]); + + const conversationFeedback = feedback.conversation ?? {}; + const gestureFeedback = feedback.gestures ?? {}; + const voiceFeedback = feedback.voice ?? {}; + + const normalizeScore = (value: unknown, max: number): number | null => { + if (value === null || value === undefined) { + return null; + } + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value.replace(/[^\d.-]/g, "")) + : Number.NaN; + + if (Number.isNaN(parsed) || !Number.isFinite(parsed)) { + return null; + } + + return clamp(Math.round(parsed), 0, max); + }; + + const conversationScore = normalizeScore(conversationFeedback.score, 40); + const gestureScore = normalizeScore(gestureFeedback.score, 50); + const voiceScore = normalizeScore(voiceFeedback.score, 10); + + const hasAnyScore = + conversationScore !== null || gestureScore !== null || voiceScore !== null; + + let overallScore = hasAnyScore + ? clamp( + (conversationScore ?? 0) + (gestureScore ?? 0) + (voiceScore ?? 0), + 0, + 100, + ) + : null; + + const toText = (value: unknown): string => { + if (Array.isArray(value)) { + return value.join("\n"); + } + if (typeof value === "string") { + return value; + } + return ""; + }; + + // ---- アドバイス加点・未達算出(DB保存はせずレスポンスへ含める) ---- + let adviceScoreAdded: number | undefined; + let adviceUnfulfilled: string | undefined; + let adviceFulfilledDetails: + | { id: string; label: string; points: number }[] + | undefined; + + let improvementPointsStr = toText( + feedback.conversation?.improvementPoints ?? feedback.improvementPoints, + ); + const backgroundAdviceMap: Record< + BackgroundKey, + { id: string; label: string }[] + > = { + library: [ + { id: "library_visit_question", label: "初めましての挨拶をする" }, + { id: "study_topic", label: "勉強や授業の話題を出す" }, + { + id: "open_question", + label: "女の子が答えやすいオープンな質問を投げる", + }, + ], + classroom: [ + { id: "class_topic", label: "授業・課題・サークルなど身近な話題を出す" }, + { id: "weekend_plan", label: "週末や放課後の軽い予定提案/質問をする" }, + { + id: "follow_up_question", + label: "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", + }, + ], + xmas: [ + { id: "xmas_topic", label: "冬/クリスマス関連話題" }, + { id: "positive_mood", label: "前向きリアクション" }, + { id: "suggest_plan", label: "軽い提案(観に行く/写真/カフェ等)" }, + ], + }; + if ( + backgroundKey && + adviceCompletedIds && + Array.isArray(adviceCompletedIds) + ) { + const adviceIdsCompleted = new Set(adviceCompletedIds); + const weightPer = 3; + const maxBonus = 8; + const candidates = backgroundAdviceMap[backgroundKey] ?? []; + const completed = candidates.filter((c) => adviceIdsCompleted.has(c.id)); + + let remaining = maxBonus; + adviceFulfilledDetails = []; + for (const item of completed) { + if (remaining <= 0) break; + const allocate = Math.min(weightPer, remaining); + adviceFulfilledDetails.push({ + id: item.id, + label: item.label, + points: allocate, + }); + remaining -= allocate; + } + + const bonus = adviceFulfilledDetails.reduce((sum, i) => sum + i.points, 0); + adviceScoreAdded = Math.min(bonus, maxBonus); + + const unfulfilled = candidates.filter((c) => !adviceIdsCompleted.has(c.id)); + if (unfulfilled.length) { + adviceUnfulfilled = unfulfilled.map((u) => u.label).join("\n"); + const lines = improvementPointsStr.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 3) { + lines.push( + `背景適合: 以下を盛り込むとさらに良い→ ${unfulfilled + .map((u) => u.label) + .slice(0, 2) + .join("、")}`, + ); + improvementPointsStr = lines.join("\n"); + } + } + + if (hasAnyScore && bonus > 0) { + const baseSum = + (conversationScore ?? 0) + (gestureScore ?? 0) + (voiceScore ?? 0); + overallScore = clamp(baseSum + bonus, 0, 100); // 総合=会話+仕草+声+アドバイス + } + } + + return { + goodPoints: toText(conversationFeedback.goodPoints ?? feedback.goodPoints), + improvementPoints: improvementPointsStr, + overallScore, + conversationScore, + gestureScore, + voiceScore, + gestureGoodPoints: toText( + gestureFeedback.goodPoints ?? feedback.gestureGoodPoints, + ), + gestureImprovementPoints: toText( + gestureFeedback.improvementPoints ?? feedback.gestureImprovementPoints, + ), + voiceMetrics, + adviceScoreAdded, + adviceUnfulfilled, + adviceFulfilledDetails, + }; } diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index 697af4b..f865d67 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -1,49 +1,49 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "renai-backend", - "main": "src/index.ts", - "compatibility_date": "2025-10-07", - "compatibility_flags": ["nodejs_compat"], - "account_id": "db74745d84b02c3798192c2602098072", - // 環境変数はCloudflareダッシュボードのSecretsで設定してください - // ELEVENLABS_API_KEY, GEMINI_API_KEY, DATABASE_URLなど - "vars": { - "NODE_ENV": "production" - }, + "$schema": "node_modules/wrangler/config-schema.json", + "name": "renai-backend", + "main": "src/index.ts", + "compatibility_date": "2025-10-07", + "compatibility_flags": ["nodejs_compat"], + "account_id": "db74745d84b02c3798192c2602098072", + // 環境変数はCloudflareダッシュボードのSecretsで設定してください + // ELEVENLABS_API_KEY, GEMINI_API_KEY, DATABASE_URLなど + "vars": { + "NODE_ENV": "production" + }, - "observability": { - "logs": { - "enabled": false, - "head_sampling_rate": 1, - "invocation_logs": true, - "persist": true - } - } + "observability": { + "logs": { + "enabled": false, + "head_sampling_rate": 1, + "invocation_logs": true, + "persist": true + } + } - // "kv_namespaces": [ - // { - // "binding": "MY_KV_NAMESPACE", - // "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - // } - // ], - // "r2_buckets": [ - // { - // "binding": "MY_BUCKET", - // "bucket_name": "my-bucket" - // } - // ], - // "d1_databases": [ - // { - // "binding": "MY_DB", - // "database_name": "my-database", - // "database_id": "" - // } - // ], - // "ai": { - // "binding": "AI" - // }, - // "observability": { - // "enabled": true, - // "head_sampling_rate": 1 - // } + // "kv_namespaces": [ + // { + // "binding": "MY_KV_NAMESPACE", + // "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + // } + // ], + // "r2_buckets": [ + // { + // "binding": "MY_BUCKET", + // "bucket_name": "my-bucket" + // } + // ], + // "d1_databases": [ + // { + // "binding": "MY_DB", + // "database_name": "my-database", + // "database_id": "" + // } + // ], + // "ai": { + // "binding": "AI" + // }, + // "observability": { + // "enabled": true, + // "head_sampling_rate": 1 + // } } diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e483ee5..d968886 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -12,9 +12,7 @@ const customJestConfig = { moduleNameMapper: { "^@/(.*)$": "/src/$1", }, - transformIgnorePatterns: [ - "/node_modules/(?!(@?nanostores|better-auth))", - ], + transformIgnorePatterns: ["/node_modules/(?!(@?nanostores|better-auth))"], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/frontend/package.json b/frontend/package.json index 49148fa..10cb9d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,55 +1,55 @@ { - "name": "frontend", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev --turbopack", - "build": "pnpx prisma generate --schema=../prisma/schema.prisma && next build --turbopack", - "start": "next start", - "test": "jest", - "test:watch": "jest --watch", - "postinstall": "" - }, - "dependencies": { - "@elevenlabs/elevenlabs-js": "^2.18.0", - "@mediapipe/tasks-vision": "0.10.22-rc.20250304", - "@pixiv/three-vrm": "^3.4.3", - "@pixiv/three-vrm-animation": "^3.4.3", - "@prisma/client": "^6.19.0", - "@radix-ui/react-slot": "^1.2.3", - "@react-three/drei": "^10.7.6", - "@react-three/fiber": "^9.3.0", - "@stripe/stripe-js": "^8.2.0", - "@supabase/supabase-js": "^2.75.0", - "agora-rtc-sdk-ng": "^4.24.0", - "apexcharts": "3.46.0", - "bcryptjs": "^3.0.2", - "better-auth": "^1.3.33", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.545.0", - "next": "15.5.4", - "prisma": "^6.19.0", - "react": "19.1.0", - "react-dom": "19.1.0", - "stripe": "^19.1.0", - "tailwind-merge": "^3.3.1", - "three": "^0.180.0" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/bcryptjs": "^3.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/three": "^0.180.0", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "pnpx prisma generate --schema=../prisma/schema.prisma && next build --turbopack", + "start": "next start", + "test": "jest", + "test:watch": "jest --watch", + "postinstall": "" + }, + "dependencies": { + "@elevenlabs/elevenlabs-js": "^2.18.0", + "@mediapipe/tasks-vision": "0.10.22-rc.20250304", + "@pixiv/three-vrm": "^3.4.3", + "@pixiv/three-vrm-animation": "^3.4.3", + "@prisma/client": "^7.0.0", + "@radix-ui/react-slot": "^1.2.3", + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.3.0", + "@stripe/stripe-js": "^8.2.0", + "@supabase/supabase-js": "^2.75.0", + "agora-rtc-sdk-ng": "^4.24.0", + "apexcharts": "3.46.0", + "bcryptjs": "^3.0.2", + "better-auth": "^1.3.33", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.545.0", + "next": "15.5.4", + "prisma": "^7.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "stripe": "^19.1.0", + "tailwind-merge": "^3.3.1", + "three": "^0.180.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^3.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/three": "^0.180.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/frontend/public/models/maki-bee.vrm b/frontend/public/models/maki-bee.vrm new file mode 100644 index 0000000..07077c4 Binary files /dev/null and b/frontend/public/models/maki-bee.vrm differ diff --git a/frontend/src/app/api/auth/update-role/route.ts b/frontend/src/app/api/auth/update-role/route.ts index d116332..94656c1 100644 --- a/frontend/src/app/api/auth/update-role/route.ts +++ b/frontend/src/app/api/auth/update-role/route.ts @@ -3,36 +3,36 @@ import { prisma } from "@/lib/prisma"; import { auth } from "@/lib/auth"; export async function POST(req: NextRequest) { - try { - const session = await auth.api.getSession({ headers: req.headers }); + try { + const session = await auth.api.getSession({ headers: req.headers }); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - const body = await req.json(); - const { role } = body; + const body = await req.json(); + const { role } = body; - if (!role || !["user", "partner", "admin"].includes(role)) { - return NextResponse.json({ error: "Invalid role" }, { status: 400 }); - } + if (!role || !["user", "partner", "admin"].includes(role)) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } - // ユーザーのroleを更新 - // partnerの場合はisAvailableもtrueに設定 - await prisma.user.update({ - where: { id: session.user.id }, - data: { - role, - ...(role === "partner" && { isAvailable: true }), - }, - }); + // ユーザーのroleを更新 + // partnerの場合はisAvailableもtrueに設定 + await prisma.user.update({ + where: { id: session.user.id }, + data: { + role, + ...(role === "partner" && { isAvailable: true }), + }, + }); - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Failed to update role:", error); - return NextResponse.json( - { error: "Failed to update role" }, - { status: 500 } - ); - } + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to update role:", error); + return NextResponse.json( + { error: "Failed to update role" }, + { status: 500 }, + ); + } } diff --git a/frontend/src/app/api/partner/availability/route.ts b/frontend/src/app/api/partner/availability/route.ts index 0d5ae5f..7cdae34 100644 --- a/frontend/src/app/api/partner/availability/route.ts +++ b/frontend/src/app/api/partner/availability/route.ts @@ -25,10 +25,7 @@ export async function POST(req: NextRequest) { } | null; if (!body || typeof body.isAvailable !== "boolean") { - return NextResponse.json( - { error: "Invalid payload" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } await prisma.user.update({ diff --git a/frontend/src/app/api/partners/available/route.ts b/frontend/src/app/api/partners/available/route.ts index cca3a07..4518c9c 100644 --- a/frontend/src/app/api/partners/available/route.ts +++ b/frontend/src/app/api/partners/available/route.ts @@ -1,27 +1,27 @@ import { type NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -export async function GET(req: NextRequest) { - try { - const partnersList = await prisma.user.findMany({ - where: { - isAvailable: true, - role: "partner", - }, - select: { - id: true, - name: true, - rating: true, - isAvailable: true, - }, - }); +export async function GET(_req: NextRequest) { + try { + const partnersList = await prisma.user.findMany({ + where: { + isAvailable: true, + role: "partner", + }, + select: { + id: true, + name: true, + rating: true, + isAvailable: true, + }, + }); - return NextResponse.json({ partners: partnersList }); - } catch (error) { - console.error("Failed to fetch partners:", error); - return NextResponse.json( - { error: "Failed to fetch partners" }, - { status: 500 }, - ); - } + return NextResponse.json({ partners: partnersList }); + } catch (error) { + console.error("Failed to fetch partners:", error); + return NextResponse.json( + { error: "Failed to fetch partners" }, + { status: 500 } + ); + } } diff --git a/frontend/src/app/api/partners/sessions/waiting/route.ts b/frontend/src/app/api/partners/sessions/waiting/route.ts index 60d5fc8..01f90ea 100644 --- a/frontend/src/app/api/partners/sessions/waiting/route.ts +++ b/frontend/src/app/api/partners/sessions/waiting/route.ts @@ -12,7 +12,8 @@ export async function GET(req: NextRequest) { }); const requesterId = session?.user?.id; - const requesterRole = (session?.user as { role?: string } | undefined)?.role; + const requesterRole = (session?.user as { role?: string } | undefined) + ?.role; if (!partnerIdQuery && requesterRole === "partner" && requesterId) { searchParams.set("partnerId", requesterId); diff --git a/frontend/src/app/api/slots/[slotId]/book/route.ts b/frontend/src/app/api/slots/[slotId]/book/route.ts index 17ea958..1726736 100644 --- a/frontend/src/app/api/slots/[slotId]/book/route.ts +++ b/frontend/src/app/api/slots/[slotId]/book/route.ts @@ -13,10 +13,7 @@ export async function POST( }); if (!authSession?.user) { - return NextResponse.json( - { error: "認証が必要です" }, - { status: 401 }, - ); + return NextResponse.json({ error: "認証が必要です" }, { status: 401 }); } const userId = authSession.user.id; diff --git a/frontend/src/app/feedback/page.tsx b/frontend/src/app/feedback/page.tsx index d93d69f..1cb138a 100644 --- a/frontend/src/app/feedback/page.tsx +++ b/frontend/src/app/feedback/page.tsx @@ -300,7 +300,9 @@ function FeedbackContent() { const countRaw = localStorage.getItem("practiceSessionCount"); const count = countRaw ? parseInt(countRaw, 10) || 0 : 0; setPracticeCount(count); - } catch {/* ignore */} + } catch { + /* ignore */ + } }, []); // Load selected avatar from localStorage to adjust visuals/voice @@ -350,7 +352,11 @@ function FeedbackContent() { let adviceCompletedIds: string[] | undefined; try { const savedBg = localStorage.getItem("selectedBackground"); - if (savedBg === "library" || savedBg === "classroom" || savedBg === "xmas") { + if ( + savedBg === "library" || + savedBg === "classroom" || + savedBg === "xmas" + ) { backgroundKey = savedBg; } if (sessionId) { @@ -358,8 +364,8 @@ function FeedbackContent() { if (raw) { const parsed = JSON.parse(raw) as Record; adviceCompletedIds = Object.entries(parsed) - .filter(([, v]) => v) - .map(([k]) => k); + .filter(([, v]) => v) + .map(([k]) => k); } } } catch { @@ -497,10 +503,17 @@ function FeedbackContent() { // 70点以上取得で実践練習解禁フラグを保存 useEffect(() => { - if (feedback?.overallScore !== null && feedback?.overallScore !== undefined && feedback.overallScore >= 70) { //testで30点にできる + if ( + feedback?.overallScore !== null && + feedback?.overallScore !== undefined && + feedback.overallScore >= 70 + ) { + //testで30点にできる try { localStorage.setItem("practiceUnlocked", "true"); - } catch {/* ignore */} + } catch { + /* ignore */ + } } }, [feedback?.overallScore]); @@ -579,7 +592,9 @@ function FeedbackContent() { if (saved === "male" || saved === "female" || saved === "neutral") { avatarForVoice = saved; } - } catch { /* ignore */ } + } catch { + /* ignore */ + } const voiceId = avatarForVoice === "male" ? config.tts.voices?.male || config.tts.voiceId || undefined @@ -694,7 +709,6 @@ function FeedbackContent() { .slice(0, 3); }, [selectedFeedback]); - const scoreChartMetrics = useMemo(() => { if (scoreHistory.length === 0) { return { @@ -881,87 +895,115 @@ function FeedbackContent() { - {/* Title */} -
-

- 会話フィードバック -

-

- AIがあなたの会話を分析しました -

-
+ {/* Title */} +
+

+ 会話フィードバック +

+

+ AIがあなたの会話を分析しました +

+
- {/* Overall Score */} - -
-
-

- 総合スコア -

-
- {feedback.overallScore} -
-

- / 100点 -

+ {/* Google Form Link */} + + + {/* Overall Score */} + +
+
+

+ 総合スコア +

+
+ {feedback.overallScore}
-
-
-

会話

-

{feedback.conversationScore ?? "—"} / 40点

-
-
-

仕草

-

{feedback.gestureScore ?? "—"} / 50点

-
-
-

-

{feedback.voiceScore ?? "—"} / 10点

-
+

+ / 100点 +

+
+
+
+

会話

+

{feedback.conversationScore ?? "—"} / 40点

+
+
+

仕草

+

{feedback.gestureScore ?? "—"} / 50点

- {typeof feedback.adviceScoreAdded === "number" && feedback.adviceScoreAdded > 0 && ( +
+

+

{feedback.voiceScore ?? "—"} / 10点

+
+
+ {typeof feedback.adviceScoreAdded === "number" && + feedback.adviceScoreAdded > 0 && (
- シチュエーション達成ボーナス +{feedback.adviceScoreAdded}点 + シチュエーション達成ボーナス +{feedback.adviceScoreAdded} + 点
)} - {adviceFulfilledDetails.length > 0 && ( - -
- -

背景適合で加点された項目

-
-
    - {adviceFulfilledDetails.map((d) => ( -
  • - {d.label} +{d.points}点 -
  • - ))} -
-
- )} - -
- + {adviceFulfilledDetails.length > 0 && ( + +
+ +

+ 背景適合で加点された項目 +

+
+
    + {adviceFulfilledDetails.map((d) => ( +
  • + {d.label}{" "} + + +{d.points}点 + +
  • + ))} +
+
+ )} + +
+
{/* Category Toggle */}
@@ -997,100 +1039,103 @@ function FeedbackContent() { )} -{/* ✅ Good Points */} -{selectedCategory !== "voice" && ( - -
-
- -
-

- 良かった点({categoryLabel}) -

-
- - {activeGoodPoints.length > 0 ? ( -
    - {activeGoodPoints.map((point, index) => ( -
  • -
    - {index + 1} -
    -

    {point}

    -
  • - ))} -
- ) : ( -

- {selectedCategory === "gesture" - ? "カメラ分析データがまだありません。カメラアクセスを許可して会話すると表示されます。" - : "良かった点が記録されていません。"} -

- )} -
-)} - -{/* ✅ Improvement Points */} -{selectedCategory !== "voice" && ( - -
-
- -
-

- 改善点({categoryLabel}) -

-
- - {activeImprovementPoints.length > 0 ? ( -
    - {activeImprovementPoints.map((point, index) => ( -
  • -
    - {index + 1} -
    -

    {point}

    -
  • - ))} -
- ) : ( -

- {selectedCategory === "gesture" - ? "仕草の改善点は、カメラ分析データが集まり次第ここに表示されます。" - : "改善点が記録されていません。"} -

- )} -
-)} - -{/* ✅ 未達成のアドバイス(会話限定) */} -{selectedCategory === "conversation" && adviceUnfulfilledList.length > 0 && ( - -
-
- -
-

- 未達成のアドバイス -

-
- -
    - {adviceUnfulfilledList.map((line, idx) => ( -
  • - {line} -
  • - ))} -
-
-)} + {/* ✅ Good Points */} + {selectedCategory !== "voice" && ( + +
+
+ +
+

+ 良かった点({categoryLabel}) +

+
+ {activeGoodPoints.length > 0 ? ( +
    + {activeGoodPoints.map((point, index) => ( +
  • +
    + {index + 1} +
    +

    {point}

    +
  • + ))} +
+ ) : ( +

+ {selectedCategory === "gesture" + ? "カメラ分析データがまだありません。カメラアクセスを許可して会話すると表示されます。" + : "良かった点が記録されていません。"} +

+ )} +
+ )} + + {/* ✅ Improvement Points */} + {selectedCategory !== "voice" && ( + +
+
+ +
+

+ 改善点({categoryLabel}) +

+
+ + {activeImprovementPoints.length > 0 ? ( +
    + {activeImprovementPoints.map((point, index) => ( +
  • +
    + {index + 1} +
    +

    {point}

    +
  • + ))} +
+ ) : ( +

+ {selectedCategory === "gesture" + ? "仕草の改善点は、カメラ分析データが集まり次第ここに表示されます。" + : "改善点が記録されていません。"} +

+ )} +
+ )} + + {/* ✅ 未達成のアドバイス(会話限定) */} + {selectedCategory === "conversation" && + adviceUnfulfilledList.length > 0 && ( + +
+
+ +
+

+ 未達成のアドバイス +

+
+ +
    + {adviceUnfulfilledList.map((line, idx) => ( +
  • + {line} +
  • + ))} +
+
+ )} {/* Score History */} @@ -1313,7 +1358,7 @@ function FeedbackContent() { {/* 実践練習へ進むボタン(高スコアの場合に表示) */} {feedback && feedback.overallScore !== null && - feedback.overallScore >= 70 && ( //testで30点にした + feedback.overallScore >= 70 && ( //testで30点にした

@@ -1330,7 +1375,8 @@ function FeedbackContent() { className="w-full rounded-full bg-green-600 hover:bg-green-700 text-white" > - 実践練習へ進む (残り {Math.max(0, 10 - practiceCount)} 回) + 実践練習へ進む (残り{" "} + {Math.max(0, 10 - practiceCount)} 回)

@@ -1377,54 +1423,62 @@ function FeedbackContent() { ) : selectedFeedback ? (
{/* スコア表示 */} - -
-

- 総合スコア -

-
- {selectedFeedback.overallScore} + +
+

+ 総合スコア +

+
+ {selectedFeedback.overallScore} +
+

/ 100点

+
+
+

会話

+

{selectedFeedback.conversationScore ?? "—"} / 40点

-

/ 100点

-
-
-

会話

-

- {selectedFeedback.conversationScore ?? "—"} / 40点 -

-
-
-

仕草

-

{selectedFeedback.gestureScore ?? "—"} / 50点

-
-
-

-

{selectedFeedback.voiceScore ?? "—"} / 10点

-
+
+

仕草

+

{selectedFeedback.gestureScore ?? "—"} / 50点

- {typeof selectedFeedback.adviceScoreAdded === "number" && selectedFeedback.adviceScoreAdded > 0 && ( +
+

+

{selectedFeedback.voiceScore ?? "—"} / 10点

+
+
+ {typeof selectedFeedback.adviceScoreAdded === "number" && + selectedFeedback.adviceScoreAdded > 0 && (
- シチュエーション達成ボーナス +{selectedFeedback.adviceScoreAdded}点 + シチュエーション達成ボーナス + + {selectedFeedback.adviceScoreAdded}点
)} - {(selectedFeedback.adviceFulfilledDetails?.length ?? 0) > 0 && ( - -
- -

背景適合で加点された項目

-
-
    - {selectedFeedback.adviceFulfilledDetails?.map((d) => ( -
  • - {d.label} +{d.points}点 -
  • - ))} -
-
- )} -
- + {(selectedFeedback.adviceFulfilledDetails?.length ?? 0) > 0 && ( + +
+ +

+ 背景適合で加点された項目 +

+
+
    + {selectedFeedback.adviceFulfilledDetails?.map((d) => ( +
  • + {d.label}{" "} + + +{d.points}点 + +
  • + ))} +
+
+ )} +
+
{/* カテゴリ切り替え */}
@@ -1477,16 +1531,14 @@ function FeedbackContent() { 良かった点({categoryLabel})
- {( - selectedCategory === "conversation" - ? selectedConversationGoodPoints - : selectedGestureGoodPoints + {(selectedCategory === "conversation" + ? selectedConversationGoodPoints + : selectedGestureGoodPoints ).length > 0 ? (
    - {( - selectedCategory === "conversation" - ? selectedConversationGoodPoints - : selectedGestureGoodPoints + {(selectedCategory === "conversation" + ? selectedConversationGoodPoints + : selectedGestureGoodPoints ).map((point, index) => (
- {( - selectedCategory === "conversation" - ? selectedConversationImprovementPoints - : selectedGestureImprovementPoints + {(selectedCategory === "conversation" + ? selectedConversationImprovementPoints + : selectedGestureImprovementPoints ).length > 0 ? (
    - {( - selectedCategory === "conversation" - ? selectedConversationImprovementPoints - : selectedGestureImprovementPoints + {(selectedCategory === "conversation" + ? selectedConversationImprovementPoints + : selectedGestureImprovementPoints ).map((point, index) => (
  • 0 && ( -
    -
    - -

    未達成のアドバイス

    + {selectedCategory === "conversation" && + selectedAdviceUnfulfilledList.length > 0 && ( +
    +
    + +

    + 未達成のアドバイス +

    +
    +
      + {selectedAdviceUnfulfilledList.map((line, idx) => ( +
    • + {line} +
    • + ))} +
    -
      - {selectedAdviceUnfulfilledList.map((line, idx) => ( -
    • - {line} -
    • - ))} -
    -
    - )} + )}
    )} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3bb62eb..df355cc 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -37,7 +37,7 @@ export default function RootLayout({ diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 49aee19..378a72d 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,236 +1,31 @@ "use client"; -import { Loader2, Lock, Mail } from "lucide-react"; -import Link from "next/link"; +import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useId, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { signIn } from "@/lib/auth-client"; +import { useEffect } from "react"; export default function LoginPage() { const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const emailInputId = useId(); - const passwordInputId = useId(); - - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(null); - - try { - await signIn.email({ - email, - password, - }); - - // ログイン成功 - router.push("/"); - } catch (err: unknown) { - console.error("Login error:", err); - if (err instanceof Error) { - setError(err.message); - } else { - setError("ログインに失敗しました"); - } - } finally { - setIsLoading(false); - } - }; - const handleGoogleLogin = async () => { - setIsLoading(true); - setError(null); - - try { - await signIn.social({ - provider: "google", - callbackURL: "/", - }); - } catch (err: unknown) { - console.error("Google login error:", err); - if (err instanceof Error) { - setError(err.message); - } else { - setError("Googleログインに失敗しました"); - } - setIsLoading(false); - } - }; + useEffect(() => { + router.replace("/practice/waiting"); + }, [router]); return ( -
    - - - {/* Main Content */} -
    -
    - {/* Title */} -
    -

    - ログイン -

    -

    - アカウントにログインして練習を始めましょう -

    -
    - - {/* Login Card */} - -
    - {/* Google Login */} - - - {/* Divider */} -
    -
    -
    -
    -
    - - または - -
    -
    - - {/* Email/Password Login */} -
    - {/* Email Input */} -
    - -
    - - setEmail(e.target.value)} - placeholder="example@email.com" - required - className="w-full pl-10 pr-4 py-2 rounded-lg border-2 border-border bg-background text-foreground focus:outline-none focus:border-primary" - /> -
    -
    - - {/* Password Input */} -
    - -
    - - setPassword(e.target.value)} - placeholder="••••••••" - required - className="w-full pl-10 pr-4 py-2 rounded-lg border-2 border-border bg-background text-foreground focus:outline-none focus:border-primary" - /> -
    -
    - - {/* Error Message */} - {error && ( -
    -

    - {error} -

    -
    - )} - - {/* Login Button */} - -
    - - {/* Forgot Password */} -
    - - パスワードを忘れた方 - -
    -
    - - - {/* Sign Up Link */} - -

    - アカウントをお持ちでない方は - - 新規登録 - -

    -
    -
    -
    +
    +
    + + 待機ルームに移動しています... +
    ); } + +/* +以前のログインフォーム実装(再開時に戻せるよう保持) +import { Loader2, Lock, Mail } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { signIn } from "@/lib/auth-client"; +... +*/ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c06ccfc..c621469 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,12 @@ "use client"; -import { Heart, MessageCircle, Sparkles, TrendingUp, Users } from "lucide-react"; +import { + Heart, + MessageCircle, + Sparkles, + TrendingUp, + Users, +} from "lucide-react"; import { useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; @@ -11,7 +17,9 @@ import { Card } from "@/components/ui/card"; export default function HomePage() { const [showPracticeButton, setShowPracticeButton] = useState(false); const [practiceCount, setPracticeCount] = useState(0); - const [centerAvatar, setCenterAvatar] = useState<"maki" | "rento" | "kouta">("maki"); + const [centerAvatar, setCenterAvatar] = useState<"maki" | "rento" | "kouta">( + "maki", + ); useEffect(() => { try { @@ -20,7 +28,9 @@ export default function HomePage() { const count = countRaw ? parseInt(countRaw, 10) || 0 : 0; setPracticeCount(count); setShowPracticeButton(unlocked && count < 10); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, []); // Avatar ordering helpers @@ -52,7 +62,9 @@ export default function HomePage() { AIコミュニケーション・コーチング

    - 異性と話せるようになろう!! + + 異性と話せるようになろう!! +

    バーチャル大学生「まき」「れんと」「こうた」との会話で、あなたのコミュニケーション力を楽しく伸ばそう! @@ -105,77 +117,77 @@ export default function HomePage() {

-
- +
+ + + + {showPracticeButton && ( + - {showPracticeButton && ( - - - - )} -
+ )} +
- {/* Features */} -
- -
-
- -
-

- リアルタイム会話 -

+ {/* Features */} +
+ +
+
+
-

- "まき"と自然な会話を楽しみながら、コミュニケーションスキルを磨けます -

- +

+ リアルタイム会話 +

+
+

+ "まき"と自然な会話を楽しみながら、コミュニケーションスキルを磨けます +

+
- -
-
- -
-

- 的確なフィードバック -

+ +
+
+
-

- 会話終了後、AIが良かった点と改善点をフィードバックします -

- +

+ 的確なフィードバック +

+
+

+ 会話終了後、AIが良かった点と改善点をフィードバックします +

+
- -
-
- -
-

- 安心して練習 -

+ +
+
+
-

- 匿名で利用可能。失敗を恐れず、何度でも練習できる安全な環境です -

- -
+

+ 安心して練習 +

+
+

+ 匿名で利用可能。失敗を恐れず、何度でも練習できる安全な環境です +

+
- - {/* removed arrow styles */} +
+ + {/* removed arrow styles */}

© 2025 diff --git a/frontend/src/app/partner-call/[sessionId]/page.tsx b/frontend/src/app/partner-call/[sessionId]/page.tsx index 07984f0..0faded6 100644 --- a/frontend/src/app/partner-call/[sessionId]/page.tsx +++ b/frontend/src/app/partner-call/[sessionId]/page.tsx @@ -254,9 +254,13 @@ export default function PartnerCallPage() { {connectionStatus === "connected" && (

残り時間: - + {formatTimeRemaining(timeRemaining)}
diff --git a/frontend/src/app/partner-feedback/[sessionId]/page.tsx b/frontend/src/app/partner-feedback/[sessionId]/page.tsx index 034525d..84005ff 100644 --- a/frontend/src/app/partner-feedback/[sessionId]/page.tsx +++ b/frontend/src/app/partner-feedback/[sessionId]/page.tsx @@ -56,9 +56,9 @@ export default function PartnerFeedbackPage() { const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [selectedCategory, setSelectedCategory] = useState< - "ai" | "partner" - >("ai"); + const [selectedCategory, setSelectedCategory] = useState<"ai" | "partner">( + "ai", + ); useEffect(() => { const fetchFeedback = async () => { @@ -147,9 +147,7 @@ export default function PartnerFeedbackPage() {
-

- フィードバックを読み込み中... -

+

フィードバックを読み込み中...

); @@ -265,8 +263,7 @@ export default function PartnerFeedbackPage() { {feedback.aiOverallScore >= 60 && feedback.aiOverallScore < 80 && "良い会話ができました!"} - {feedback.aiOverallScore < 60 && - "次回はもっと良くなります!"} + {feedback.aiOverallScore < 60 && "次回はもっと良くなります!"}

@@ -285,9 +282,7 @@ export default function PartnerFeedbackPage() { {feedback?.partnerRating && ( - // - //
- // ); - // } - - // if (!isPartner) { - // return ( - //
- // - //
- //

- // パートナー権限が必要です - //

- //

- // このページは認定パートナーのみが利用できます。ログインし直すか管理者にお問い合わせください。 - //

- //
- // - // - // - //
- // ); - // } - - return ( -
-
-
- - - パートナーダッシュボードに戻る - -
- - {sessionData?.user?.name ?? "パートナー"} -
-
-
- -
-
-
-

- Partner Control Room -

-

- ユーザー待機ルーム -

-

- AI練習を終えたユーザーが表示されます。セッションIDを確認し、準備ができたら通話を開始してください。 -

-
- - -
-
- -
-

待機フロー

-

- ユーザーがパートナーを選択すると即座にセッションIDが発行されます。 -

-
-
- -
- -
-
- -
-

- 待機中ユーザー -

-

- {isLoading ? "-" : waitingCount} -

-
-
-
- -
-

自動更新

-

15秒ごと

-
-
-
- -
-

最新取得

-

- {lastUpdated - ? lastUpdated.toLocaleTimeString("ja-JP", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) - : "-"} -

-
-
-
- - {error && ( - - -
-

- 情報の取得に失敗しました -

-

{error}

-
-
- )} -
- - -
-
-

- 待機中のセッション -

-

- ユーザー名とセッションIDを確認して、通話を開始してください -

-
- -
- - {isLoading ? ( -
- -

待機中セッションを読み込み中...

-
- ) : sessions.length === 0 ? ( -
-

- 現在待機中のユーザーはいません。 -

-

- ユーザーがパートナーを選択すると自動的に表示されます。 -

-
- ) : ( -
- {sessions.map((session) => ( - -
-
-
-
- {session.user?.name - ? session.user.name.charAt(0).toUpperCase() - : "U"} -
-
-

- {session.user?.name ?? "匿名ユーザー"} -

-

- 待機開始:{" "} - {new Date(session.createdAt).toLocaleString( - "ja-JP", - { - hour: "2-digit", - minute: "2-digit", - }, - )} -

-
-
-
-

- セッションID -

-
- - {session.id} - - -
-
-
- -
- -

- 通話開始後、自動でステータスが更新されます。 -

-
-
-
- ))} -
- )} -
- - -
- -
-

- スムーズな接続のために -

-

- 開始前にカメラとマイクのチェックを済ませ、笑顔でユーザーを迎えてください。 -

-
-
-
-
-
-
- ); + const router = useRouter(); + const sessionState = useSession(); + const { data: sessionData, isPending } = sessionState; + + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [joiningSessionId, setJoiningSessionId] = useState(null); + + const fetchWaitingSessions = useCallback(async () => { + setError(null); + try { + const params = new URLSearchParams(); + if (sessionData?.user?.id) { + params.set("partnerId", sessionData.user.id); + } + const response = await fetch( + `/api/partners/sessions/waiting${params.size > 0 ? `?${params.toString()}` : ""}`, + { + cache: "no-store", + }, + ); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const message = + typeof payload?.error === "string" + ? payload.error + : "待機中セッションの取得に失敗しました。"; + setError(message); + setSessions([]); + return; + } + + const data = (await response.json()) as { sessions: WaitingSession[] }; + setSessions( + data.sessions?.filter((session) => + sessionData?.user?.id + ? session.partner?.id === sessionData.user.id + : true, + ) ?? [], + ); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load waiting sessions:", err); + setError("待機中セッションの取得に失敗しました。"); + setSessions([]); + } finally { + setIsLoading(false); + } + }, [sessionData?.user?.id]); + + useEffect(() => { + void fetchWaitingSessions(); + const interval = setInterval(() => { + void fetchWaitingSessions(); + }, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchWaitingSessions]); + + const waitingCount = useMemo(() => sessions.length, [sessions]); + + const handleCopy = useCallback(async (sessionId: string) => { + try { + await navigator.clipboard.writeText(sessionId); + } catch { + // ignore clipboard failures + } + }, []); + + const handleJoinSession = useCallback( + async (sessionId: string, userName?: string | null) => { + setJoiningSessionId(sessionId); + try { + await fetch(`/api/partners/sessions/${sessionId}/start`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + } catch (err) { + console.error("Failed to mark session active:", err); + } finally { + router.push( + `/partner-call/${sessionId}?partnerName=${encodeURIComponent( + userName || "ユーザー", + )}`, + ); + setJoiningSessionId(null); + } + }, + [router], + ); + + if (isPending) { + return ( +
+
+ + セッション情報を読み込み中... +
+
+ ); + } + + // if (!sessionData?.user) { + // return ( + //
+ // + //
+ //

ログインが必要です

+ //

+ // このページにアクセスするにはパートナーアカウントでログインしてください。 + //

+ //
+ // + // + // + //
+ // ); + // } + + // if (!isPartner) { + // return ( + //
+ // + //
+ //

+ // パートナー権限が必要です + //

+ //

+ // このページは認定パートナーのみが利用できます。ログインし直すか管理者にお問い合わせください。 + //

+ //
+ // + // + // + //
+ // ); + // } + + return ( +
+
+
+ + + パートナーダッシュボードに戻る + +
+ + {sessionData?.user?.name ?? "パートナー"} +
+
+
+ +
+
+
+

+ Partner Control Room +

+

+ ユーザー待機ルーム +

+

+ AI練習を終えたユーザーが表示されます。セッションIDを確認し、準備ができたら通話を開始してください。 +

+
+ + +
+
+ +
+

待機フロー

+

+ ユーザーがパートナーを選択すると即座にセッションIDが発行されます。 +

+
+
+ +
+ +
+
+ +
+

+ 待機中ユーザー +

+

+ {isLoading ? "-" : waitingCount} +

+
+
+
+ +
+

自動更新

+

15秒ごと

+
+
+
+ +
+

最新取得

+

+ {lastUpdated + ? lastUpdated.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : "-"} +

+
+
+
+ + {error && ( + + +
+

+ 情報の取得に失敗しました +

+

{error}

+
+
+ )} +
+ + +
+
+

+ 待機中のセッション +

+

+ ユーザー名とセッションIDを確認して、通話を開始してください +

+
+ +
+ + {isLoading ? ( +
+ +

待機中セッションを読み込み中...

+
+ ) : sessions.length === 0 ? ( +
+

+ 現在待機中のユーザーはいません。 +

+

+ ユーザーがパートナーを選択すると自動的に表示されます。 +

+
+ ) : ( +
+ {sessions.map((session) => ( + +
+
+
+
+ {session.user?.name + ? session.user.name.charAt(0).toUpperCase() + : "U"} +
+
+

+ {session.user?.name ?? "匿名ユーザー"} +

+

+ 待機開始:{" "} + {new Date(session.createdAt).toLocaleString( + "ja-JP", + { + hour: "2-digit", + minute: "2-digit", + }, + )} +

+
+
+
+

+ セッションID +

+
+ + {session.id} + + +
+
+
+ +
+ +

+ 通話開始後、自動でステータスが更新されます。 +

+
+
+
+ ))} +
+ )} +
+ + +
+ +
+

+ スムーズな接続のために +

+

+ 開始前にカメラとマイクのチェックを済ませ、笑顔でユーザーを迎えてください。 +

+
+
+
+
+
+
+ ); } diff --git a/frontend/src/app/practice/waiting/page.tsx b/frontend/src/app/practice/waiting/page.tsx index b440ed3..ff2f72d 100644 --- a/frontend/src/app/practice/waiting/page.tsx +++ b/frontend/src/app/practice/waiting/page.tsx @@ -1,16 +1,16 @@ "use client"; import { - AlertCircle, - ArrowLeft, - CheckCircle2, - Clock, - Loader2, - RefreshCcw, - ShieldCheck, - Star, - Users, - Video, + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + Loader2, + RefreshCcw, + ShieldCheck, + Star, + Users, + Video, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -20,502 +20,467 @@ import { Card } from "@/components/ui/card"; import { useSession } from "@/lib/auth-client"; type AvailablePartner = { - id: string; - name: string; - rating?: number | null; - isAvailable: boolean; + id: string; + name: string; + rating?: number | null; + isAvailable: boolean; }; type MatchResult = { - sessionId: string; - roomId: string; - partner: AvailablePartner; + sessionId: string; + roomId: string; + partner: AvailablePartner; }; const REFRESH_INTERVAL_MS = 20_000; export default function PracticeWaitingPage() { - const router = useRouter(); - const sessionState = useSession(); - const { data: sessionData, isPending } = sessionState; - const user = sessionData?.user ?? null; - const [partners, setPartners] = useState([]); - const [partnersError, setPartnersError] = useState(null); - const [isLoadingPartners, setIsLoadingPartners] = useState(true); - const [matchingPartnerId, setMatchingPartnerId] = useState( - null, - ); - const [matchError, setMatchError] = useState(null); - const [activeMatch, setActiveMatch] = useState(null); - const [copied, setCopied] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(false); - - // 匿名認証を実行 - useEffect(() => { - const authenticateAnonymously = async () => { - // セッション読み込み中はスキップ - if (isPending) return; - - // 既にログイン済みの場合はスキップ - if (sessionData?.user) return; - - // 認証中フラグが立っている場合はスキップ - if (isAuthenticating) return; - - setIsAuthenticating(true); - - try { - // 匿名ユーザーとしてサインイン - const anonymousEmail = `user-${crypto.randomUUID()}@anonymous.local`; - const anonymousPassword = crypto.randomUUID(); - - console.log('[Practice Waiting] Creating anonymous user account...'); - - // サインアップとサインインを試みる - const signUpResponse = await fetch('/api/auth/sign-up/email', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: anonymousEmail, - password: anonymousPassword, - name: `ユーザー${Math.floor(Math.random() * 1000)}`, - }), - }); - - if (signUpResponse.ok) { - console.log('[Practice Waiting] Anonymous account created, updating role...'); - - // roleをuserに更新(明示的に設定) - const updateRoleResponse = await fetch('/api/auth/update-role', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'user' }), - }); - - if (updateRoleResponse.ok) { - console.log('[Practice Waiting] Role updated to user'); - } - - // ページをリロードしてセッションを更新 - window.location.reload(); - } - } catch (error) { - console.error('[Practice Waiting] Anonymous authentication failed:', error); - } finally { - setIsAuthenticating(false); - } - }; - - void authenticateAnonymously(); - }, [isPending, sessionData?.user, isAuthenticating]); - - const loadPartners = useCallback(async () => { - setPartnersError(null); - setIsLoadingPartners(true); - try { - const response = await fetch("/api/partners/available", { - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }); - - if (!response.ok) { - throw new Error("パートナー情報の取得に失敗しました。"); - } - - const data = (await response.json()) as { - partners: AvailablePartner[]; - }; - setPartners(data.partners ?? []); - } catch (error) { - console.error("Failed to fetch partners:", error); - setPartnersError( - error instanceof Error - ? error.message - : "パートナー情報の取得に失敗しました。", - ); - } finally { - setIsLoadingPartners(false); - } - }, []); - - useEffect(() => { - void loadPartners(); - const timer = setInterval(() => { - void loadPartners(); - }, REFRESH_INTERVAL_MS); - return () => clearInterval(timer); - }, [loadPartners]); - - const handleSelectPartner = useCallback( - async (partner: AvailablePartner) => { - if (!user?.id) { - setMatchError("マッチングにはログインが必要です。"); - router.push("/login"); - return; - } - - setMatchError(null); - setMatchingPartnerId(partner.id); - - try { - const response = await fetch("/api/partners/sessions/create", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - userId: user.id, - partnerId: partner.id, - }), - }); - - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - const message = - typeof payload?.error === "string" - ? payload.error - : "セッションの作成に失敗しました。"; - throw new Error(message); - } - - const data = (await response.json()) as { - sessionId: string; - roomId: string; - }; - - setActiveMatch({ - sessionId: data.sessionId, - roomId: data.roomId, - partner, - }); - - try { - const currentCount = - parseInt(localStorage.getItem("practiceSessionCount") ?? "0", 10) || - 0; - localStorage.setItem( - "practiceSessionCount", - String(Math.min(currentCount + 1, 10)), - ); - } catch { - // ignore quota errors - } - } catch (error) { - console.error("Failed to match partner:", error); - setMatchError( - error instanceof Error ? error.message : "マッチングに失敗しました。", - ); - } finally { - setMatchingPartnerId(null); - } - }, - [user?.id, router], - ); - - const handleCopySessionId = useCallback(async () => { - if (!activeMatch) return; - try { - await navigator.clipboard.writeText(activeMatch.sessionId); - setCopied(true); - setTimeout(() => setCopied(false), 1_800); - } catch { - setCopied(false); - } - }, [activeMatch]); - - const handleResetMatch = () => { - setActiveMatch(null); - setMatchError(null); - }; - - const availableCount = useMemo(() => { - return partners.filter((partner) => partner.isAvailable).length; - }, [partners]); - - if (isPending || isAuthenticating) { - return ( -
-
- - {isAuthenticating ? '匿名認証中...' : 'セッション情報を読み込み中...'} -
-
- ); - } - - return ( -
-
-
- - - 練習ページに戻る - - - - -
-
- -
-
-
-

- Live Partner Queue -

-

- 実践練習の待機ルーム -

-

- リアルパートナーを選択すると、同じセッションIDでビデオ通話が開始できます。 -

-
- - -
-
- -
-

マッチングの流れ

-

- パートナーを選ぶとセッションIDが発行され、同じIDで双方が接続します。 -

-
-
- -
- -
-
- -
-

- 待機中のパートナー -

-

- {isLoadingPartners ? "-" : availableCount} -

-
-
-
- -
-

平均待ち時間

-

1-2分

-
-
-
- -
-

通話環境

-

暗号化済み

-
-
-
-
- - {matchError && ( - - -
-

- マッチングに失敗しました -

-

{matchError}

-
-
- )} - - {activeMatch ? ( - -
- -
-

- マッチング完了 -

-

- {activeMatch.partner.name} さんと接続準備OK -

-
-
- -
-
-

セッションID

-

- {activeMatch.sessionId} -

- -
-
-

ルームID

-

- {activeMatch.roomId} -

-
-
- -
- - -
- -

- このIDはパートナーページにも共有され、同じセッションIDで接続されます。 -

-
- ) : ( - -
-
-

- 待機中のパートナー -

-

- オンラインのパートナーから選んでください -

-
- -
- - {partnersError && ( -
- {partnersError} -
- )} - - {isLoadingPartners ? ( -
- -

パートナー一覧を読み込み中...

-
- ) : partners.length === 0 ? ( -
-

- 現在待機中のパートナーはいません。 -

-

- しばらく待ってから再読み込みしてください。 -

-
- ) : ( -
- {partners.map((partner) => ( - -
-
-

- {partner.name} -

-
- - - {partner.rating - ? partner.rating.toFixed(1) - : "評価準備中"} - -
-
- - {partner.isAvailable ? "待機中" : "離席中"} - -
- -
- ))} -
- )} -
- )} -
-
-
- ); + const router = useRouter(); + const sessionState = useSession(); + const { data: sessionData, isPending } = sessionState; + const user = sessionData?.user ?? null; + const [partners, setPartners] = useState([]); + const [partnersError, setPartnersError] = useState(null); + const [isLoadingPartners, setIsLoadingPartners] = useState(true); + const [matchingPartnerId, setMatchingPartnerId] = useState( + null, + ); + const [matchError, setMatchError] = useState(null); + const [activeMatch, setActiveMatch] = useState(null); + const [copied, setCopied] = useState(false); + + const loadPartners = useCallback(async () => { + setPartnersError(null); + setIsLoadingPartners(true); + try { + const response = await fetch("/api/partners/available", { + headers: { + "Content-Type": "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error("パートナー情報の取得に失敗しました。"); + } + + const data = (await response.json()) as { + partners: AvailablePartner[]; + }; + setPartners(data.partners ?? []); + } catch (error) { + console.error("Failed to fetch partners:", error); + setPartnersError( + error instanceof Error + ? error.message + : "パートナー情報の取得に失敗しました。", + ); + } finally { + setIsLoadingPartners(false); + } + }, []); + + useEffect(() => { + void loadPartners(); + const timer = setInterval(() => { + void loadPartners(); + }, REFRESH_INTERVAL_MS); + return () => clearInterval(timer); + }, [loadPartners]); + + const handleSelectPartner = useCallback( + async (partner: AvailablePartner) => { + if (!user?.id) { + setMatchError("マッチングにはログインが必要です。"); + router.push("/login"); + return; + } + + setMatchError(null); + setMatchingPartnerId(partner.id); + + try { + const response = await fetch("/api/partners/sessions/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: user.id, + partnerId: partner.id, + }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const message = + typeof payload?.error === "string" + ? payload.error + : "セッションの作成に失敗しました。"; + throw new Error(message); + } + + const data = (await response.json()) as { + sessionId: string; + roomId: string; + }; + + setActiveMatch({ + sessionId: data.sessionId, + roomId: data.roomId, + partner, + }); + + try { + const currentCount = + parseInt(localStorage.getItem("practiceSessionCount") ?? "0", 10) || + 0; + localStorage.setItem( + "practiceSessionCount", + String(Math.min(currentCount + 1, 10)), + ); + } catch { + // ignore quota errors + } + } catch (error) { + console.error("Failed to match partner:", error); + setMatchError( + error instanceof Error ? error.message : "マッチングに失敗しました。", + ); + } finally { + setMatchingPartnerId(null); + } + }, + [user?.id, router], + ); + + const handleCopySessionId = useCallback(async () => { + if (!activeMatch) return; + try { + await navigator.clipboard.writeText(activeMatch.sessionId); + setCopied(true); + setTimeout(() => setCopied(false), 1_800); + } catch { + setCopied(false); + } + }, [activeMatch]); + + const handleResetMatch = () => { + setActiveMatch(null); + setMatchError(null); + }; + + const availableCount = useMemo(() => { + return partners.filter((partner) => partner.isAvailable).length; + }, [partners]); + + if (isPending) { + return ( +
+
+ + セッション情報を読み込み中... +
+
+ ); + } + + if (!user) { + return ( +
+ +

+ ログインが必要です +

+

+ 実践練習の待機ルームを利用するには、アカウントにログインしてください。 +

+
+ + + + + + +
+
+
+ ); + } + + return ( +
+
+
+ + + 練習ページに戻る + + + + +
+
+ +
+
+
+

+ Live Partner Queue +

+

+ 実践練習の待機ルーム +

+

+ リアルパートナーを選択すると、同じセッションIDでビデオ通話が開始できます。 +

+
+ + +
+
+ +
+

マッチングの流れ

+

+ パートナーを選ぶとセッションIDが発行され、同じIDで双方が接続します。 +

+
+
+ +
+ +
+
+ +
+

+ 待機中のパートナー +

+

+ {isLoadingPartners ? "-" : availableCount} +

+
+
+
+ +
+

平均待ち時間

+

1-2分

+
+
+
+ +
+

通話環境

+

暗号化済み

+
+
+
+
+ + {matchError && ( + + +
+

+ マッチングに失敗しました +

+

{matchError}

+
+
+ )} + + {activeMatch ? ( + +
+ +
+

+ マッチング完了 +

+

+ {activeMatch.partner.name} さんと接続準備OK +

+
+
+ +
+
+

セッションID

+

+ {activeMatch.sessionId} +

+ +
+
+

ルームID

+

+ {activeMatch.roomId} +

+
+
+ +
+ + +
+ +

+ このIDはパートナーページにも共有され、同じセッションIDで接続されます。 +

+
+ ) : ( + +
+
+

+ 待機中のパートナー +

+

+ オンラインのパートナーから選んでください +

+
+ +
+ + {partnersError && ( +
+ {partnersError} +
+ )} + + {isLoadingPartners ? ( +
+ +

パートナー一覧を読み込み中...

+
+ ) : partners.length === 0 ? ( +
+

+ 現在待機中のパートナーはいません。 +

+

+ しばらく待ってから再読み込みしてください。 +

+
+ ) : ( +
+ {partners.map((partner) => ( + +
+
+

+ {partner.name} +

+
+ + + {partner.rating + ? partner.rating.toFixed(1) + : "評価準備中"} + +
+
+ + {partner.isAvailable ? "待機中" : "離席中"} + +
+ +
+ ))} +
+ )} +
+ )} +
+
+
+ ); } diff --git a/frontend/src/app/session/[sessionId]/page.tsx b/frontend/src/app/session/[sessionId]/page.tsx index 0072068..cc20303 100644 --- a/frontend/src/app/session/[sessionId]/page.tsx +++ b/frontend/src/app/session/[sessionId]/page.tsx @@ -58,8 +58,7 @@ export default function SessionRoomPage() { // userIdを一度だけ生成 if (!userId) { const id = - session?.user?.id || - `user-${Math.random().toString(36).substring(7)}`; + session?.user?.id || `user-${Math.random().toString(36).substring(7)}`; setUserId(id); console.log("[Session] Generated userId:", id); } @@ -231,7 +230,8 @@ export default function SessionRoomPage() {

練習セッション

{connectionState === "connecting" && "接続中..."} - {connectionState === "connected" && formatDuration(callDuration)} + {connectionState === "connected" && + formatDuration(callDuration)} {connectionState === "disconnected" && "通話終了"}

diff --git a/frontend/src/app/simulation/page.tsx b/frontend/src/app/simulation/page.tsx index 527f1df..fd7189e 100644 --- a/frontend/src/app/simulation/page.tsx +++ b/frontend/src/app/simulation/page.tsx @@ -19,7 +19,6 @@ import { useConversation } from "@/hooks/useConversation"; import { useFacialAnalysis } from "@/hooks/useFacialAnalysis"; import { useGestureTracking } from "@/hooks/useGestureTracking"; import { useMediaDevices } from "@/hooks/useMediaDevices"; -import { useSimulationTimer } from "@/hooks/useSimulationTimer"; import { BACKGROUND_ADVICE } from "@/lib/advice"; import { AdviceChecklist } from "@/components/simulation/AdviceChecklist"; import { preloadVRM } from "@/hooks/useVRM"; @@ -73,7 +72,9 @@ export default function SimulationPage() { const [selectedBackground, setSelectedBackground] = useState("library"); const [assistMode, setAssistMode] = useState(true); - const [adviceCompleted, setAdviceCompleted] = useState>({}); + const [adviceCompleted, setAdviceCompleted] = useState< + Record + >({}); const [showAdvicePanel, setShowAdvicePanel] = useState(false); const videoStreamRef = useRef(null); @@ -81,10 +82,10 @@ export default function SimulationPage() { const avatarModelUrl = useMemo(() => { if (selectedAvatar === "male") return "/models/rento.vrm"; if (selectedAvatar === "neutral") return "/models/kouta.vrm"; - return "/models/maki.vrm"; // female + return "/models/maki-bee.vrm"; // female }, [selectedAvatar]); - // 背景の保存/復元 + // 背景の保存/復元s useEffect(() => { try { const saved = localStorage.getItem( @@ -197,12 +198,17 @@ export default function SimulationPage() { onLipSyncUpdate: handleLipSyncUpdate, ttsVoiceId: selectedVoiceId || undefined, onEmotionUpdate: setAvatarEmotion, - avatarId: selectedAvatar === "female" ? "maki" : selectedAvatar === "male" ? "rento" : "kouta", + avatarId: + selectedAvatar === "female" + ? "maki" + : selectedAvatar === "male" + ? "rento" + : "kouta", }); // 最新ユーザー発話でアドバイス達成判定(state反映遅延による未達表示不安定性を解消) useEffect(() => { if (!messages.length) return; - const lastUser = [...messages].reverse().find(m => m.role === 'user'); + const lastUser = [...messages].reverse().find((m) => m.role === "user"); if (!lastUser) return; const text = lastUser.content; const adviceList = BACKGROUND_ADVICE[selectedBackground]; @@ -210,7 +216,7 @@ export default function SimulationPage() { let changed = false; for (const item of adviceList) { if (next[item.id]) continue; - if (item.patterns.some(re => re.test(text))) { + if (item.patterns.some((re) => re.test(text))) { next[item.id] = true; changed = true; } @@ -219,7 +225,10 @@ export default function SimulationPage() { setAdviceCompleted(next); try { if (session?.id) { - localStorage.setItem(`adviceCompleted:${session.id}`, JSON.stringify(next)); + localStorage.setItem( + `adviceCompleted:${session.id}`, + JSON.stringify(next), + ); } } catch {} } @@ -238,10 +247,10 @@ export default function SimulationPage() { }, [session?.id]); // 背景変更時にアドバイス進捗リセット -useEffect(() => { - setAdviceCompleted({}); - void selectedBackground; -}, [selectedBackground]); + useEffect(() => { + setAdviceCompleted({}); + void selectedBackground; + }, [selectedBackground]); useEffect(() => { if (!adviceAvailable) { @@ -249,12 +258,6 @@ useEffect(() => { } }, [adviceAvailable]); - // Timer management - const { timeRemaining, startTimer, stopTimer } = useSimulationTimer({ - conversationStarted, - onTimeout: handleEndConversation, - }); - // Preload VRM model when selected avatar changes useEffect(() => { logMediaRecorderSupport(); @@ -280,8 +283,6 @@ useEffect(() => { // End conversation handler async function handleEndConversation() { - stopTimer(); - if (isRecording) { stopRecording(); } @@ -332,11 +333,10 @@ useEffect(() => { await startSession(); setConversationStarted(true); - startTimer(5 * 60); // 10 minutes } catch (error) { console.error("Failed to start conversation:", error); } - }, [startStream, startSession, startTimer, resetGestures]); + }, [startStream, startSession, resetGestures]); // Toggle recording const toggleRecording = useCallback(() => { @@ -404,7 +404,8 @@ useEffect(() => { setAvatarGesture("talking"); } else { const gestures: GestureType[] = ["idle", "idle", "idle"]; - const randomGesture = gestures[Math.floor(Math.random() * gestures.length)]; + const randomGesture = + gestures[Math.floor(Math.random() * gestures.length)]; setAvatarGesture(randomGesture); } }, [isRecording, isProcessing, lipSyncValue]); @@ -601,8 +602,12 @@ useEffect(() => {
{/* Assist Mode Toggle (下段表示) */}
-

モード選択

-

アシストモードでは会話中に高得点のコツ(アドバイス)を表示します。

+

+ モード選択 +

+

+ アシストモードでは会話中に高得点のコツ(アドバイス)を表示します。 +

@@ -654,7 +661,9 @@ useEffect(() => { gesture={avatarGesture} avatarName={avatarName} backgroundSrc={BACKGROUNDS[selectedBackground].src} - disableGreeting={BACKGROUNDS[selectedBackground].label === "入学式"} + disableGreeting={ + BACKGROUNDS[selectedBackground].label === "入学式" + } /> {adviceAvailable && (
@@ -726,7 +735,7 @@ useEffect(() => { stream={stream} audioURL={audioURL} showControls={showControls} - timeRemaining={timeRemaining} + timeRemaining={null} messageCount={messages.length} avatarName={avatarName} onToggleRecording={toggleRecording} diff --git a/frontend/src/app/test-call/page.tsx b/frontend/src/app/test-call/page.tsx index da1f1ef..fc2665c 100644 --- a/frontend/src/app/test-call/page.tsx +++ b/frontend/src/app/test-call/page.tsx @@ -19,49 +19,49 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; export default function TestCallPage() { - const router = useRouter(); - const [sessionId, setSessionId] = useState(`test-session-${Date.now()}`); - const [userConnected, setUserConnected] = useState(false); - const [partnerConnected, setPartnerConnected] = useState(false); - const [secondsRemaining, setSecondsRemaining] = useState(null); - const timerRef = useRef(null); - const [timerStarted, setTimerStarted] = useState(false); - - const endCall = useCallback(() => { - // 接続終了を他ウィンドウへ通知 - try { - localStorage.setItem(`webrtc_end_${sessionId}`, String(Date.now())); - } catch { - /* ignore */ - } - // 接続終了時はホームへ戻る(フィードバック生成は行わない) - router.push("/"); - }, [router, sessionId]); - - // 他ウィンドウの接続状態(localStorage経由)を監視 - useEffect(() => { - const keyUser = `webrtc_connected_${sessionId}_user`; - const keyPartner = `webrtc_connected_${sessionId}_partner`; - - const readState = () => { - try { - setUserConnected(localStorage.getItem(keyUser) === "true"); - setPartnerConnected(localStorage.getItem(keyPartner) === "true"); - } catch { - /* ignore */ - } - }; - - readState(); - const onStorage = (e: StorageEvent) => { - if (!e.key) return; - if (e.key === keyUser || e.key === keyPartner) { - readState(); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, [sessionId]); + const router = useRouter(); + const [sessionId, setSessionId] = useState(`test-session-${Date.now()}`); + const [userConnected, setUserConnected] = useState(false); + const [partnerConnected, setPartnerConnected] = useState(false); + const [secondsRemaining, setSecondsRemaining] = useState(null); + const timerRef = useRef(null); + const [timerStarted, setTimerStarted] = useState(false); + + const endCall = useCallback(() => { + // 接続終了を他ウィンドウへ通知 + try { + localStorage.setItem(`webrtc_end_${sessionId}`, String(Date.now())); + } catch { + /* ignore */ + } + // 接続終了時はホームへ戻る(フィードバック生成は行わない) + router.push("/"); + }, [router, sessionId]); + + // 他ウィンドウの接続状態(localStorage経由)を監視 + useEffect(() => { + const keyUser = `webrtc_connected_${sessionId}_user`; + const keyPartner = `webrtc_connected_${sessionId}_partner`; + + const readState = () => { + try { + setUserConnected(localStorage.getItem(keyUser) === "true"); + setPartnerConnected(localStorage.getItem(keyPartner) === "true"); + } catch { + /* ignore */ + } + }; + + readState(); + const onStorage = (e: StorageEvent) => { + if (!e.key) return; + if (e.key === keyUser || e.key === keyPartner) { + readState(); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [sessionId]); // 両者接続後に 3 分カウントダウン開始 useEffect(() => { @@ -71,164 +71,164 @@ export default function TestCallPage() { } }, [userConnected, partnerConnected, timerStarted]); - // カウントダウン進行 - useEffect(() => { - if (secondsRemaining === null) return; - if (secondsRemaining <= 0) { - endCall(); - return; - } - timerRef.current && clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - setSecondsRemaining((prev) => (prev !== null ? prev - 1 : prev)); - }, 1000); - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [secondsRemaining, endCall]); - - const formatTime = (sec: number) => { - const m = Math.floor(sec / 60); - const s = sec % 60; - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; - }; - - const joinAsUser = () => { - // 新しいウィンドウで開き、ダッシュボード(このページ)は残す - window.open(`/session/${sessionId}?role=user`, "_blank"); - }; - - const joinAsPartner = () => { - window.open(`/session/${sessionId}?role=partner`, "_blank"); - }; - - return ( -
- -
- -

- WebRTC通話テスト -

-

開発者向けのテストページです

-
- -
-
- - setSessionId(e.target.value)} - className="w-full px-4 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" - placeholder="test-session-123" - /> -

- 両方のブラウザで同じセッションIDを使用してください -

-
- -
-

テスト方法:

-
-

- 手順: -

-
    -
  1. 「ユーザーとして参加」をクリック
  2. -
  3. - 「パートナーとして参加」をクリック(新しいウィンドウが開く) -
  4. -
  5. - 両ウィンドウで接続処理完了後、自動的にカウントダウン開始 -
  6. -
-
-
- -
- - -
- - -

- 接続状態 -

-
-
- ユーザー: {userConnected ? "接続" : "未接続"} -
-
- パートナー: {partnerConnected ? "接続" : "未接続"} -
-
- {!timerStarted && ( -

- 両方が接続すると3分タイマーが開始します。 -

- )} - {timerStarted && secondsRemaining !== null && ( -
-

- 残り時間:{" "} - - {formatTime(secondsRemaining)} - -

- -
- )} -
- -
-

確認項目:

-
    -
  • ✓ 両方の画面で接続が「接続」になるか
  • -
  • ✓ 接続後に3分タイマーが開始されるか
  • -
  • ✓ タイマー終了または終了ボタンでホームへ戻るか
  • -
-
-
-
-
- ); + // カウントダウン進行 + useEffect(() => { + if (secondsRemaining === null) return; + if (secondsRemaining <= 0) { + endCall(); + return; + } + timerRef.current && clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setSecondsRemaining((prev) => (prev !== null ? prev - 1 : prev)); + }, 1000); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [secondsRemaining, endCall]); + + const formatTime = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + }; + + const joinAsUser = () => { + // 新しいウィンドウで開き、ダッシュボード(このページ)は残す + window.open(`/session/${sessionId}?role=user`, "_blank"); + }; + + const joinAsPartner = () => { + window.open(`/session/${sessionId}?role=partner`, "_blank"); + }; + + return ( +
+ +
+ +

+ WebRTC通話テスト +

+

開発者向けのテストページです

+
+ +
+
+ + setSessionId(e.target.value)} + className="w-full px-4 py-2 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="test-session-123" + /> +

+ 両方のブラウザで同じセッションIDを使用してください +

+
+ +
+

テスト方法:

+
+

+ 手順: +

+
    +
  1. 「ユーザーとして参加」をクリック
  2. +
  3. + 「パートナーとして参加」をクリック(新しいウィンドウが開く) +
  4. +
  5. + 両ウィンドウで接続処理完了後、自動的にカウントダウン開始 +
  6. +
+
+
+ +
+ + +
+ + +

+ 接続状態 +

+
+
+ ユーザー: {userConnected ? "接続" : "未接続"} +
+
+ パートナー: {partnerConnected ? "接続" : "未接続"} +
+
+ {!timerStarted && ( +

+ 両方が接続すると3分タイマーが開始します。 +

+ )} + {timerStarted && secondsRemaining !== null && ( +
+

+ 残り時間:{" "} + + {formatTime(secondsRemaining)} + +

+ +
+ )} +
+ +
+

確認項目:

+
    +
  • ✓ 両方の画面で接続が「接続」になるか
  • +
  • ✓ 接続後に3分タイマーが開始されるか
  • +
  • ✓ タイマー終了または終了ボタンでホームへ戻るか
  • +
+
+
+
+
+ ); } diff --git a/frontend/src/components/Avatar/ConversationAvatar.tsx b/frontend/src/components/Avatar/ConversationAvatar.tsx index 4024ebd..17ec638 100644 --- a/frontend/src/components/Avatar/ConversationAvatar.tsx +++ b/frontend/src/components/Avatar/ConversationAvatar.tsx @@ -25,14 +25,14 @@ interface ConversationAvatarProps { * 背景画像を表示するコンポーネント(与えられたsrcを使う) */ function BackgroundImageWithSrc({ src }: { src: string }) { - const texture = useTexture(src); + const texture = useTexture(src); - return ( - - - - - ); + return ( + + + + + ); } /** @@ -66,7 +66,9 @@ export default function ConversationAvatar({ lipSyncValue={lipSyncValue} emotion={emotion} gesture={gesture} - disableGreeting={disableGreeting || backgroundSrc?.includes("springschool")} + disableGreeting={ + disableGreeting || backgroundSrc?.includes("springschool") + } /> { if (!vrm || !isReady) return; @@ -433,30 +432,55 @@ export default function VRMAvatar({ // URL候補 const preferredUrls: string[] = (() => { - if (emo === "bashful") return ["/animations/bashful.vrma", gestureToVrmaPath.idle]; - if (emo === "angry") return ["/animations/angry.vrma", gestureToVrmaPath.nodding, gestureToVrmaPath.idle]; - if (emo === "sad") return ["/animations/sad.vrma", gestureToVrmaPath.thinking, gestureToVrmaPath.idle]; - return [gestureToVrmaPath[ges] ?? gestureToVrmaPath.idle, gestureToVrmaPath.idle]; + if (emo === "bashful") + return ["/animations/bashful.vrma", gestureToVrmaPath.idle]; + if (emo === "angry") + return [ + "/animations/angry.vrma", + gestureToVrmaPath.nodding, + gestureToVrmaPath.idle, + ]; + if (emo === "sad") + return [ + "/animations/sad.vrma", + gestureToVrmaPath.thinking, + gestureToVrmaPath.idle, + ]; + return [ + gestureToVrmaPath[ges] ?? gestureToVrmaPath.idle, + gestureToVrmaPath.idle, + ]; })(); let chosenUrl: string | null = null; let finalClip: THREE.AnimationClip | null = null; for (const u of preferredUrls) { - if (lastPlayedUrlRef.current === u && lastEmotionRef.current === emo) return; // 変更不要 + if (lastPlayedUrlRef.current === u && lastEmotionRef.current === emo) + return; // 変更不要 // eslint-disable-next-line no-await-in-loop const clip = await loadVrmaClip(u); - if (clip) { chosenUrl = u; finalClip = clip; break; } + if (clip) { + chosenUrl = u; + finalClip = clip; + break; + } } if (!finalClip || !chosenUrl) return; const fadeSec = (() => { switch (emo) { - case "bashful": return 0.4; - case "sad": return 0.35; - case "happy": return 0.3; - case "angry": return 0.2; - case "surprised": return 0.15; - default: return 0.25; + case "bashful": + return 0.4; + case "sad": + return 0.35; + case "happy": + return 0.3; + case "angry": + return 0.2; + case "surprised": + return 0.15; + default: + return 0.25; } })(); @@ -483,7 +507,16 @@ export default function VRMAvatar({ // すぐに切替実行 performSwitch(emotion, gesture); - }, [emotion, gesture, gestureToVrmaPath, isReady, loadVrmaClip, playClip, vrm, disableGreeting]); + }, [ + emotion, + gesture, + gestureToVrmaPath, + isReady, + loadVrmaClip, + playClip, + vrm, + disableGreeting, + ]); if (error) { console.error("VRM load error:", error); diff --git a/frontend/src/components/simulation/AdviceChecklist.tsx b/frontend/src/components/simulation/AdviceChecklist.tsx index 803da45..612a85a 100644 --- a/frontend/src/components/simulation/AdviceChecklist.tsx +++ b/frontend/src/components/simulation/AdviceChecklist.tsx @@ -64,9 +64,7 @@ export function AdviceChecklist({ isLarge ? "text-xs sm:text-sm leading-relaxed" : "text-xs leading-snug" - } ${ - item.checked ? "text-foreground" : "text-muted-foreground" - }`} + } ${item.checked ? "text-foreground" : "text-muted-foreground"}`} > {item.label} diff --git a/frontend/src/components/simulation/StatusIndicators.tsx b/frontend/src/components/simulation/StatusIndicators.tsx index f680a1d..aa3b8c7 100644 --- a/frontend/src/components/simulation/StatusIndicators.tsx +++ b/frontend/src/components/simulation/StatusIndicators.tsx @@ -43,16 +43,27 @@ export function ErrorDisplay({ // STT関連: Unsupported language メッセージをより親しみやすく表示 let friendlyMessage = error?.message; if (friendlyMessage) { - if (friendlyMessage.includes("UNSUPPORTED_LANGUAGE") || friendlyMessage.includes("日本語または英語で話してください")) { - friendlyMessage = "日本語か英語で話してみましょう。方言や特殊記号は認識できない場合があります。"; + if ( + friendlyMessage.includes("UNSUPPORTED_LANGUAGE") || + friendlyMessage.includes("日本語または英語で話してください") + ) { + friendlyMessage = + "日本語か英語で話してみましょう。方言や特殊記号は認識できない場合があります。"; } // APIキー未設定 - if (friendlyMessage.includes("API key not configured") || friendlyMessage.includes("API_KEY_NOT_CONFIGURED")) { + if ( + friendlyMessage.includes("API key not configured") || + friendlyMessage.includes("API_KEY_NOT_CONFIGURED") + ) { friendlyMessage = "音声認識設定が未構成です。管理者に連絡してください。"; } // 接続失敗 - if (friendlyMessage.includes("バックエンドサーバーに接続できません") || friendlyMessage.includes("Failed to connect")) { - friendlyMessage = "サーバーに接続できません。バックエンドが起動しているか、ネットワーク/プロキシ設定を確認してください。"; + if ( + friendlyMessage.includes("バックエンドサーバーに接続できません") || + friendlyMessage.includes("Failed to connect") + ) { + friendlyMessage = + "サーバーに接続できません。バックエンドが起動しているか、ネットワーク/プロキシ設定を確認してください。"; } } diff --git a/frontend/src/hooks/useConversation.ts b/frontend/src/hooks/useConversation.ts index f73777d..818d561 100644 --- a/frontend/src/hooks/useConversation.ts +++ b/frontend/src/hooks/useConversation.ts @@ -147,16 +147,21 @@ export function useConversation(options: UseConversationOptions) { const unsupportedErr = new Error( "日本語または英語で話してください (Detected unsupported language).", ); - setState((prev) => ({ ...prev, error: unsupportedErr, isProcessing: false })); + setState((prev) => ({ + ...prev, + error: unsupportedErr, + isProcessing: false, + })); return null; } // 2. AI: テキストから応答を生成 - const relationshipStage = state.messages.length < 7 - ? "shy" - : state.messages.length < 15 - ? "friendly" - : "open"; + const relationshipStage = + state.messages.length < 7 + ? "shy" + : state.messages.length < 15 + ? "friendly" + : "open"; const aiResponse = await conversationApi.generateResponse({ sessionId: state.session.id, userMessage: sttResult.text, @@ -239,7 +244,15 @@ export function useConversation(options: UseConversationOptions) { return null; } }, - [state.session, systemPrompt, ttsVoiceId, onAudioReady, onEmotionUpdate, avatarId, state.messages.length], + [ + state.session, + systemPrompt, + ttsVoiceId, + onAudioReady, + onEmotionUpdate, + avatarId, + state.messages.length, + ], ); // クリーンアップ diff --git a/frontend/src/lib/advice.ts b/frontend/src/lib/advice.ts index 2b0d75d..70dd11a 100644 --- a/frontend/src/lib/advice.ts +++ b/frontend/src/lib/advice.ts @@ -1,65 +1,67 @@ export type BackgroundKey = "library" | "classroom" | "xmas"; export type AdviceItem = { - id: string; - label: string; - // ユーザー発話に対するマッチ条件(いずれかがヒットすれば達成) - patterns: RegExp[]; + id: string; + label: string; + // ユーザー発話に対するマッチ条件(いずれかがヒットすれば達成) + patterns: RegExp[]; }; export const BACKGROUND_ADVICE: Record = { - library: [ - { - id: "library_visit_question", - label: "初めましての挨拶をする", - patterns: [ - /はじめまして|初めまして|初めてまして/i, - /よろしく(お願いします|ね)?/i, - ], - }, - { - id: "study_topic", - label: "勉強や授業の話題を出す", - patterns: [/勉強|授業|講義|単位|履修|ゼミ|レポート|課題|教科/i], - }, - { - id: "open_question", - label: "女の子が答えやすいオープンな質問を投げる", - patterns: [/なんで|どうして|どう思う|どんな|おすすめ|理由|きっかけ/i], - }, - ], - classroom: [ - { - id: "class_topic", - label: "授業・課題・サークルなど身近な話題を出す", - patterns: [/授業|課題|テスト|サークル|部活|先生|教室|放課後/i], - }, - { - id: "weekend_plan", - label: "週末や放課後の軽い予定提案/質問をする", - patterns: [/週末|土日|放課後|今度|行かない|行こう|空いてる|予定/i], - }, - { - id: "follow_up_question", - label: "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", - patterns: [/それって|例えば|具体的に|どれくらい|いつ(から|頃)|なんの|どこで|どこに/i], - }, - ], - xmas: [ - { - id: "xmas_topic", - label: "冬/クリスマスの話題(イルミ・プレゼント・予定)", - patterns: [/クリスマス|イルミ|冬|プレゼント|サンタ|年末|初詣|ケーキ/i], - }, - { - id: "positive_mood", - label: "明るく前向きなリアクションを返す", - patterns: [/いいね|楽しみ|うれしい|ワクワク|最高|素敵|かわいい|綺麗/i], - }, - { - id: "suggest_plan", - label: "軽い提案(見に行く/写真/カフェ等)をする", - patterns: [/見に行|写真|撮ろ|カフェ|行こう|寄ろう|一緒に|観に行/i], - }, - ], + library: [ + { + id: "library_visit_question", + label: "初めましての挨拶をする", + patterns: [ + /はじめまして|初めまして|初めてまして/i, + /よろしく(お願いします|ね)?/i, + ], + }, + { + id: "study_topic", + label: "勉強や授業の話題を出す", + patterns: [/勉強|授業|講義|単位|履修|ゼミ|レポート|課題|教科/i], + }, + { + id: "open_question", + label: "女の子が答えやすいオープンな質問を投げる", + patterns: [/なんで|どうして|どう思う|どんな|おすすめ|理由|きっかけ/i], + }, + ], + classroom: [ + { + id: "class_topic", + label: "授業・課題・サークルなど身近な話題を出す", + patterns: [/授業|課題|テスト|サークル|部活|先生|教室|放課後/i], + }, + { + id: "weekend_plan", + label: "週末や放課後の軽い予定提案/質問をする", + patterns: [/週末|土日|放課後|今度|行かない|行こう|空いてる|予定/i], + }, + { + id: "follow_up_question", + label: "相手発言に具体的な掘り下げ質問(いつ/どこ/どれくらい 等)", + patterns: [ + /それって|例えば|具体的に|どれくらい|いつ(から|頃)|なんの|どこで|どこに/i, + ], + }, + ], + xmas: [ + { + id: "xmas_topic", + label: "冬/クリスマスの話題(イルミ・プレゼント・予定)", + patterns: [/クリスマス|イルミ|冬|プレゼント|サンタ|年末|初詣|ケーキ/i], + }, + { + id: "positive_mood", + label: "明るく前向きなリアクションを返す", + patterns: [/いいね|楽しみ|うれしい|ワクワク|最高|素敵|かわいい|綺麗/i], + }, + { + id: "suggest_plan", + label: "軽い提案(見に行く/写真/カフェ等)をする", + patterns: [/見に行|写真|撮ろ|カフェ|行こう|寄ろう|一緒に|観に行/i], + }, + ], }; diff --git a/frontend/src/lib/api/tts.ts b/frontend/src/lib/api/tts.ts index ccebfd3..f4586ce 100644 --- a/frontend/src/lib/api/tts.ts +++ b/frontend/src/lib/api/tts.ts @@ -6,16 +6,16 @@ import { config } from "../config"; */ export interface TextToSpeechRequest { - text: string; - voiceId?: string; // オプションに変更(バックエンドがデフォルトを使用) - modelId?: string; + text: string; + voiceId?: string; // オプションに変更(バックエンドがデフォルトを使用) + modelId?: string; } export interface TextToSpeechOptions { - maxRetries?: number; - retryDelay?: number; - fallbackToSilence?: boolean; - useCache?: boolean; + maxRetries?: number; + retryDelay?: number; + fallbackToSilence?: boolean; + useCache?: boolean; } /** @@ -23,144 +23,144 @@ export interface TextToSpeechOptions { * @returns 音声データのReadableStream */ export async function textToSpeech( - request: TextToSpeechRequest + request: TextToSpeechRequest, ): Promise> { - try { - // Build request body, only include voiceId if it's provided - const requestBody: Record = { - text: request.text, - modelId: request.modelId || "eleven_v3", - }; - - if (request.voiceId) { - requestBody.voiceId = request.voiceId; - } - - const response = await fetch(`${config.api.baseUrl}/api/tts`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorData = await response.json(); - errorMessage = errorData.error || errorData.message || errorMessage; - // Include additional error details if available - if (errorData.details) { - errorMessage += ` - ${errorData.details}`; - } - } catch { - // JSONのパースに失敗した場合はデフォルトのエラーメッセージを使用 - } - throw new Error(errorMessage); - } - - if (!response.body) { - throw new Error("Response body is null"); - } - - return response.body; - } catch (error) { - // Re-throw with better error message if it's a network error - if (error instanceof TypeError && error.message.includes("fetch")) { - throw new Error( - `Network error: Failed to connect to TTS API at ${config.api.baseUrl}/api/tts. Make sure the backend server is running.` - ); - } - throw error; - } + try { + // Build request body, only include voiceId if it's provided + const requestBody: Record = { + text: request.text, + modelId: request.modelId || "eleven_v3", + }; + + if (request.voiceId) { + requestBody.voiceId = request.voiceId; + } + + const response = await fetch(`${config.api.baseUrl}/api/tts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + let errorMessage = `HTTP error! status: ${response.status}`; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorData.message || errorMessage; + // Include additional error details if available + if (errorData.details) { + errorMessage += ` - ${errorData.details}`; + } + } catch { + // JSONのパースに失敗した場合はデフォルトのエラーメッセージを使用 + } + throw new Error(errorMessage); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + return response.body; + } catch (error) { + // Re-throw with better error message if it's a network error + if (error instanceof TypeError && error.message.includes("fetch")) { + throw new Error( + `Network error: Failed to connect to TTS API at ${config.api.baseUrl}/api/tts. Make sure the backend server is running.`, + ); + } + throw error; + } } /** * 指定されたミリ秒待機 */ function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** * テキストを音声に変換して、Audio要素で再生可能なURLを返す(キャッシュ & リトライ機能付き) */ export async function textToSpeechUrl( - request: TextToSpeechRequest, - options: TextToSpeechOptions = {} + request: TextToSpeechRequest, + options: TextToSpeechOptions = {}, ): Promise { - const { - maxRetries = 3, - retryDelay = 1000, - fallbackToSilence = true, - useCache = true, - } = options; - - // キャッシュチェック(voiceIdがある場合のみ) - if (useCache && request.voiceId) { - const cachedUrl = audioCache.get(request.text, request.voiceId); - if (cachedUrl) { - console.log("TTS cache hit for:", request.text.substring(0, 50)); - return cachedUrl; - } - } - - let lastError: Error | null = null; - - // リトライループ - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - if (attempt > 0) { - console.log(`TTS retry attempt ${attempt}/${maxRetries}`); - await sleep(retryDelay * attempt); // 指数バックオフ - } - - const stream = await textToSpeech(request); - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - - // ストリームからすべてのチャンクを読み取る - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - - // チャンクを結合してBlobを作成 - const blob = new Blob(chunks as BlobPart[], { type: "audio/mpeg" }); - const audioUrl = URL.createObjectURL(blob); - - // キャッシュに保存(voiceIdがある場合のみ) - if (useCache && request.voiceId) { - audioCache.set(request.text, request.voiceId, audioUrl); - } - - return audioUrl; - } catch (error) { - // Better error serialization - if (error instanceof Error) { - lastError = error; - } else if (typeof error === "object" && error !== null) { - // Try to extract meaningful information from the error object - const errorMsg = JSON.stringify(error, null, 2); - lastError = new Error(errorMsg); - } else { - lastError = new Error(String(error)); - } - - console.error(`TTS attempt ${attempt + 1} failed:`, lastError.message); - // Log full error details separately for better debugging - console.error("Full error details:", error); - } - } - - // すべてのリトライが失敗した場合 - if (fallbackToSilence) { - console.warn("TTS failed after all retries, falling back to silent audio"); - return createSilentAudio(); - } - - throw lastError || new Error("TTS failed"); + const { + maxRetries = 3, + retryDelay = 1000, + fallbackToSilence = true, + useCache = true, + } = options; + + // キャッシュチェック(voiceIdがある場合のみ) + if (useCache && request.voiceId) { + const cachedUrl = audioCache.get(request.text, request.voiceId); + if (cachedUrl) { + console.log("TTS cache hit for:", request.text.substring(0, 50)); + return cachedUrl; + } + } + + let lastError: Error | null = null; + + // リトライループ + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + console.log(`TTS retry attempt ${attempt}/${maxRetries}`); + await sleep(retryDelay * attempt); // 指数バックオフ + } + + const stream = await textToSpeech(request); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + + // ストリームからすべてのチャンクを読み取る + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + // チャンクを結合してBlobを作成 + const blob = new Blob(chunks as BlobPart[], { type: "audio/mpeg" }); + const audioUrl = URL.createObjectURL(blob); + + // キャッシュに保存(voiceIdがある場合のみ) + if (useCache && request.voiceId) { + audioCache.set(request.text, request.voiceId, audioUrl); + } + + return audioUrl; + } catch (error) { + // Better error serialization + if (error instanceof Error) { + lastError = error; + } else if (typeof error === "object" && error !== null) { + // Try to extract meaningful information from the error object + const errorMsg = JSON.stringify(error, null, 2); + lastError = new Error(errorMsg); + } else { + lastError = new Error(String(error)); + } + + console.error(`TTS attempt ${attempt + 1} failed:`, lastError.message); + // Log full error details separately for better debugging + console.error("Full error details:", error); + } + } + + // すべてのリトライが失敗した場合 + if (fallbackToSilence) { + console.warn("TTS failed after all retries, falling back to silent audio"); + return createSilentAudio(); + } + + throw lastError || new Error("TTS failed"); } /** @@ -168,69 +168,69 @@ export async function textToSpeechUrl( * チャンクが到着次第、順次再生を開始 */ export async function textToSpeechStreaming( - request: TextToSpeechRequest, - options: TextToSpeechOptions = {} + request: TextToSpeechRequest, + options: TextToSpeechOptions = {}, ): Promise<{ - audioUrl: string; - audioElement: HTMLAudioElement; + audioUrl: string; + audioElement: HTMLAudioElement; }> { - const { useCache = true } = options; - - // キャッシュチェック(voiceIdがある場合のみ) - if (useCache && request.voiceId) { - const cachedUrl = audioCache.get(request.text, request.voiceId); - if (cachedUrl) { - console.log( - "TTS streaming cache hit for:", - request.text.substring(0, 50) - ); - const audio = new Audio(cachedUrl); - return { audioUrl: cachedUrl, audioElement: audio }; - } - } - - // ストリーミング取得 - const stream = await textToSpeech(request); - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - - // MediaSourceを使用してストリーミング再生 - // 注: MediaSourceはmp3で直接使用できないため、実際には全チャンクを集める必要がある - // しかし、最初のチャンクが到着したらすぐに再生準備を開始できる - - // すべてのチャンクを収集 - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - - const blob = new Blob(chunks as BlobPart[], { type: "audio/mpeg" }); - const audioUrl = URL.createObjectURL(blob); - - // キャッシュに保存(voiceIdがある場合のみ) - if (useCache && request.voiceId) { - audioCache.set(request.text, request.voiceId, audioUrl); - } - - const audio = new Audio(audioUrl); - return { audioUrl, audioElement: audio }; + const { useCache = true } = options; + + // キャッシュチェック(voiceIdがある場合のみ) + if (useCache && request.voiceId) { + const cachedUrl = audioCache.get(request.text, request.voiceId); + if (cachedUrl) { + console.log( + "TTS streaming cache hit for:", + request.text.substring(0, 50), + ); + const audio = new Audio(cachedUrl); + return { audioUrl: cachedUrl, audioElement: audio }; + } + } + + // ストリーミング取得 + const stream = await textToSpeech(request); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + + // MediaSourceを使用してストリーミング再生 + // 注: MediaSourceはmp3で直接使用できないため、実際には全チャンクを集める必要がある + // しかし、最初のチャンクが到着したらすぐに再生準備を開始できる + + // すべてのチャンクを収集 + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const blob = new Blob(chunks as BlobPart[], { type: "audio/mpeg" }); + const audioUrl = URL.createObjectURL(blob); + + // キャッシュに保存(voiceIdがある場合のみ) + if (useCache && request.voiceId) { + audioCache.set(request.text, request.voiceId, audioUrl); + } + + const audio = new Audio(audioUrl); + return { audioUrl, audioElement: audio }; } /** * 無音の音声を生成(フォールバック用) */ function createSilentAudio(): string { - // 短い無音のMP3データ(Base64エンコード) - const silentMp3 = - "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxjjZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZEYP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ=="; - - const binaryString = atob(silentMp3); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - const blob = new Blob([bytes], { type: "audio/mpeg" }); - return URL.createObjectURL(blob); + // 短い無音のMP3データ(Base64エンコード) + const silentMp3 = + "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxjjZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZEYP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ=="; + + const binaryString = atob(silentMp3); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: "audio/mpeg" }); + return URL.createObjectURL(blob); } diff --git a/frontend/src/lib/audio/voiceAnalysis.ts b/frontend/src/lib/audio/voiceAnalysis.ts index 3986fdc..a082ee3 100644 --- a/frontend/src/lib/audio/voiceAnalysis.ts +++ b/frontend/src/lib/audio/voiceAnalysis.ts @@ -205,7 +205,7 @@ function estimateTempoScore( ): number { if (!durationSeconds || durationSeconds <= 0) return 0; const voicedPerSecond = - (pitchSummary.pitchValues.length / durationSeconds) || 0; + pitchSummary.pitchValues.length / durationSeconds || 0; const minRate = 1.5; const maxRate = 5.5; return clamp01((voicedPerSecond - minRate) / (maxRate - minRate)); diff --git a/frontend/src/lib/prisma.ts b/frontend/src/lib/prisma.ts index 70963c6..246aac4 100644 --- a/frontend/src/lib/prisma.ts +++ b/frontend/src/lib/prisma.ts @@ -1,15 +1,19 @@ import { PrismaClient } from "../generated/prisma"; const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined; + prisma: PrismaClient | undefined; }; +// ビルド時のフォールバック用のダミーURL +const databaseUrl = process.env.DATABASE_URL || "postgresql://dummy:dummy@localhost:5432/dummy"; + export const prisma = - globalForPrisma.prisma ?? - new PrismaClient({ - log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], - }); + globalForPrisma.prisma ?? + new PrismaClient({ + accelerateUrl: databaseUrl, + log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], + }); if (process.env.NODE_ENV !== "production") { - globalForPrisma.prisma = prisma; + globalForPrisma.prisma = prisma; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b32f41e..e00d768 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -72,7 +72,9 @@ export interface Feedback { voiceScore?: number | null; adviceScoreAdded?: number | null; // アドバイス達成による加点 adviceUnfulfilled?: string | null; // 未達成アドバイス一覧(改行区切り) - adviceFulfilledDetails?: { id: string; label: string; points: number }[] | null; // 達成アドバイス詳細(実際に加点されたポイント) + adviceFulfilledDetails?: + | { id: string; label: string; points: number }[] + | null; // 達成アドバイス詳細(実際に加点されたポイント) conversationId: string; createdAt: string; updatedAt: string; diff --git a/package.json b/package.json index 1c3244b..558ea65 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "tsx": "^4.20.6" }, "dependencies": { - "@prisma/client": "^6.17.0", - "prisma": "^6.17.0" + "@prisma/client": "^7.0.0", + "@prisma/client-runtime-utils": "^7.0.0", + "prisma": "^7.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d577767..4dd4313 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,14 @@ importers: .: dependencies: '@prisma/client': - specifier: ^6.17.0 - version: 6.17.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client-runtime-utils': + specifier: ^7.0.0 + version: 7.0.0 prisma: - specifier: ^6.17.0 - version: 6.17.0(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3) devDependencies: '@biomejs/biome': specifier: ^2.2.5 @@ -52,11 +55,11 @@ importers: specifier: ^15.5.5 version: 15.5.5 '@prisma/client': - specifier: ^6.19.0 - version: 6.19.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3) '@prisma/extension-accelerate': specifier: ^2.0.2 - version: 2.0.2(@prisma/client@6.19.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3)) + version: 2.0.2(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3)) '@supabase/supabase-js': specifier: ^2.75.0 version: 2.75.0 @@ -92,8 +95,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 prisma: - specifier: ^6.17.0 - version: 6.17.0(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -116,8 +119,8 @@ importers: specifier: ^3.4.3 version: 3.4.3(three@0.180.0) '@prisma/client': - specifier: ^6.19.0 - version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.2.2)(react@19.1.0) @@ -158,8 +161,8 @@ importers: specifier: 15.5.4 version: 15.5.4(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) prisma: - specifier: ^6.19.0 - version: 6.19.0(typescript@5.9.3) + specifier: ^7.0.0 + version: 7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3) react: specifier: 19.1.0 version: 19.1.0 @@ -486,6 +489,18 @@ packages: cpu: [x64] os: [win32] + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -567,6 +582,20 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@electric-sql/pglite-socket@0.0.6': + resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7': + resolution: {integrity: sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==} + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': + resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==} + '@elevenlabs/elevenlabs-js@2.18.0': resolution: {integrity: sha512-nHePkqhcxon43Oms87EhrAYHBhZ7Lhdiw4TkQiwM3zSDFzJ/8tWlrwUAFnS2oZi6fRQAdAwON1NJsz3yStKsQg==} engines: {node: '>=18.0.0'} @@ -746,6 +775,12 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hono/node-server@1.14.2': + resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.5': resolution: {integrity: sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==} engines: {node: '>=18.14.1'} @@ -1142,6 +1177,10 @@ packages: peerDependencies: three: '>= 0.159.0' + '@mrleebo/prisma-ast@0.12.1': + resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==} + engines: {node: '>=16'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1327,53 +1366,38 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} - '@prisma/client@6.17.0': - resolution: {integrity: sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg==} - engines: {node: '>=18.18'} - peerDependencies: - prisma: '*' - typescript: '>=5.1.0' - peerDependenciesMeta: - prisma: - optional: true - typescript: - optional: true + '@prisma/client-runtime-utils@7.0.0': + resolution: {integrity: sha512-PAiFgMBPrLSaakBwUpML5NevipuKSL3rtNr8pZ8CZ3OBXo0BFcdeGcBIKw/CxJP6H4GNa4+l5bzJPrk8Iq6tDw==} - '@prisma/client@6.19.0': - resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==} - engines: {node: '>=18.18'} + '@prisma/client@7.0.0': + resolution: {integrity: sha512-FM1NtJezl0zH3CybLxcbJwShJt7xFGSRg+1tGhy3sCB8goUDnxnBR+RC/P35EAW8gjkzx7kgz7bvb0MerY2VSw==} + engines: {node: ^20.19 || ^22.12 || ^24.0} peerDependencies: prisma: '*' - typescript: '>=5.1.0' + typescript: '>=5.4.0' peerDependenciesMeta: prisma: optional: true typescript: optional: true - '@prisma/config@6.17.0': - resolution: {integrity: sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==} - - '@prisma/config@6.19.0': - resolution: {integrity: sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==} + '@prisma/config@7.0.0': + resolution: {integrity: sha512-TDASB57hyGUwHB0IPCSkoJcXFrJOKA1+R/1o4np4PbS+E0F5MiY5aAyUttO0mSuNQaX7t8VH/GkDemffF1mQzg==} - '@prisma/debug@6.17.0': - resolution: {integrity: sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==} + '@prisma/debug@6.8.2': + resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==} - '@prisma/debug@6.19.0': - resolution: {integrity: sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==} + '@prisma/debug@7.0.0': + resolution: {integrity: sha512-SdS3qzfMASHtWimywtkiRcJtrHzacbmMVhElko3DYUZSB0TTLqRYWpddRBJdeGgSLmy1FD55p7uGzIJ+MtfhMg==} - '@prisma/engines-version@6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a': - resolution: {integrity: sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==} + '@prisma/dev@0.13.0': + resolution: {integrity: sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==} - '@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773': - resolution: {integrity: sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==} + '@prisma/engines-version@6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513': + resolution: {integrity: sha512-7bzyN8Gp9GbDFbTDzVUH9nFcgRWvsWmjrGgBJvIC/zEoAuv/lx62gZXgAKfjn/HoPkxz/dS+TtsnduFx8WA+cw==} - '@prisma/engines@6.17.0': - resolution: {integrity: sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==} - - '@prisma/engines@6.19.0': - resolution: {integrity: sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==} + '@prisma/engines@7.0.0': + resolution: {integrity: sha512-ojCL3OFLMCz33UbU9XwH32jwaeM+dWb8cysTuY8eK6ZlMKXJdy6ogrdG3MGB3meKLGdQBmOpUUGJ7eLIaxbrcg==} '@prisma/extension-accelerate@2.0.2': resolution: {integrity: sha512-yZK6/k7uOEFpEsKoZezQS1CKDboPtBCQ0NyI70e1Un8tDiRgg80iWGyjsJmRpps2ZIut3MroHP+dyR3wVKh8lA==} @@ -1381,17 +1405,24 @@ packages: peerDependencies: '@prisma/client': '>=4.16.1' - '@prisma/fetch-engine@6.17.0': - resolution: {integrity: sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==} + '@prisma/fetch-engine@7.0.0': + resolution: {integrity: sha512-qcyWTeWDjVDaDQSrVIymZU1xCYlvmwCzjA395lIuFjUESOH3YQCb8i/hpd4vopfq3fUR4v6+MjjtIGvnmErQgw==} - '@prisma/fetch-engine@6.19.0': - resolution: {integrity: sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==} + '@prisma/get-platform@6.8.2': + resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==} - '@prisma/get-platform@6.17.0': - resolution: {integrity: sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==} + '@prisma/get-platform@7.0.0': + resolution: {integrity: sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg==} - '@prisma/get-platform@6.19.0': - resolution: {integrity: sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==} + '@prisma/query-plan-executor@6.18.0': + resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==} + + '@prisma/studio-core-licensed@0.8.0': + resolution: {integrity: sha512-SXCcgFvo/SC6/11kEOaQghJgCWNEWZUvPYKn/gpvMB9HLSG/5M8If7dWZtEQHhchvl8bh9A89Hw6mEKpsXFimA==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} @@ -1898,6 +1929,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -2059,6 +2094,9 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2210,6 +2248,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2255,9 +2297,6 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - effect@3.16.12: - resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==} - effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} @@ -2416,6 +2455,9 @@ packages: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2432,6 +2474,9 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2476,6 +2521,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.11: + resolution: {integrity: sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -2499,6 +2547,10 @@ packages: hls.js@1.6.13: resolution: {integrity: sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==} + hono@4.7.10: + resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==} + engines: {node: '>=16.9.0'} + hono@4.9.10: resolution: {integrity: sha512-AlI15ijFyKTXR7eHo7QK7OR4RoKIedZvBuRjO8iy4zrxvlY5oFCdiRG/V/lFJHCNXJ0k72ATgnyzx8Yqa5arug==} engines: {node: '>=16.9.0'} @@ -2514,6 +2566,9 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2531,6 +2586,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2584,6 +2643,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2880,6 +2942,10 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2887,12 +2953,26 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.545.0: resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==} peerDependencies: @@ -2984,6 +3064,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.3: + resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} + engines: {node: '>=12.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3162,6 +3250,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} @@ -3173,29 +3265,25 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - prisma@6.17.0: - resolution: {integrity: sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==} - engines: {node: '>=18.18'} + prisma@7.0.0: + resolution: {integrity: sha512-VZObZ1pQV/OScarYg68RYUx61GpFLH2mJGf9fUX4XxQxTst/6ZK7nkY86CSZ3zBW6U9lKRTsBrZWVz20X5G/KQ==} + engines: {node: ^20.19 || ^22.12 || ^24.0} hasBin: true peerDependencies: - typescript: '>=5.1.0' + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: - typescript: + better-sqlite3: optional: true - - prisma@6.19.0: - resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==} - engines: {node: '>=18.18'} - hasBin: true - peerDependencies: - typescript: '>=5.1.0' - peerDependenciesMeta: typescript: optional: true promise-worker-transferable@1.0.4: resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -3264,6 +3352,12 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + + remeda@2.21.3: + resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3283,6 +3377,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rou3@0.5.1: resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} @@ -3320,6 +3418,9 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3387,6 +3488,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -3400,6 +3505,9 @@ packages: stats.js@0.17.0: resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -3619,6 +3727,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3670,6 +3782,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.1.0: + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -3813,6 +3933,9 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zeptomatch@2.0.2: + resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -4146,6 +4269,21 @@ snapshots: '@biomejs/cli-win32-x64@2.2.5': optional: true + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0 @@ -4199,6 +4337,16 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': {} + '@elevenlabs/elevenlabs-js@2.18.0': dependencies: command-exists: 1.2.9 @@ -4311,6 +4459,10 @@ snapshots: '@hexagon/base64@1.1.28': {} + '@hono/node-server@1.14.2(hono@4.7.10)': + dependencies: + hono: 4.7.10 + '@hono/node-server@1.19.5(hono@4.9.10)': dependencies: hono: 4.9.10 @@ -4744,6 +4896,11 @@ snapshots: promise-worker-transferable: 1.0.4 three: 0.180.0 + '@mrleebo/prisma-ast@0.12.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.6.0 @@ -4968,31 +5125,16 @@ snapshots: '@poppinss/exception@1.2.2': {} - '@prisma/client@6.17.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3)': - optionalDependencies: - prisma: 6.17.0(typescript@5.9.3) - typescript: 5.9.3 + '@prisma/client-runtime-utils@7.0.0': {} - '@prisma/client@6.19.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3)': - optionalDependencies: - prisma: 6.17.0(typescript@5.9.3) - typescript: 5.9.3 - - '@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.0.0 optionalDependencies: - prisma: 6.19.0(typescript@5.9.3) + prisma: 7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@6.17.0': - dependencies: - c12: 3.1.0 - deepmerge-ts: 7.1.5 - effect: 3.16.12 - empathic: 2.0.0 - transitivePeerDependencies: - - magicast - - '@prisma/config@6.19.0': + '@prisma/config@7.0.0': dependencies: c12: 3.1.0 deepmerge-ts: 7.1.5 @@ -5001,51 +5143,66 @@ snapshots: transitivePeerDependencies: - magicast - '@prisma/debug@6.17.0': {} + '@prisma/debug@6.8.2': {} - '@prisma/debug@6.19.0': {} + '@prisma/debug@7.0.0': {} - '@prisma/engines-version@6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a': {} + '@prisma/dev@0.13.0(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.3.2 + '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2) + '@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2) + '@hono/node-server': 1.14.2(hono@4.7.10) + '@mrleebo/prisma-ast': 0.12.1 + '@prisma/get-platform': 6.8.2 + '@prisma/query-plan-executor': 6.18.0 + foreground-child: 3.3.1 + get-port-please: 3.1.2 + hono: 4.7.10 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.21.3 + std-env: 3.9.0 + valibot: 1.1.0(typescript@5.9.3) + zeptomatch: 2.0.2 + transitivePeerDependencies: + - typescript - '@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773': {} + '@prisma/engines-version@6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513': {} - '@prisma/engines@6.17.0': + '@prisma/engines@7.0.0': dependencies: - '@prisma/debug': 6.17.0 - '@prisma/engines-version': 6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a - '@prisma/fetch-engine': 6.17.0 - '@prisma/get-platform': 6.17.0 + '@prisma/debug': 7.0.0 + '@prisma/engines-version': 6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513 + '@prisma/fetch-engine': 7.0.0 + '@prisma/get-platform': 7.0.0 - '@prisma/engines@6.19.0': + '@prisma/extension-accelerate@2.0.2(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3))': dependencies: - '@prisma/debug': 6.19.0 - '@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 - '@prisma/fetch-engine': 6.19.0 - '@prisma/get-platform': 6.19.0 + '@prisma/client': 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(typescript@5.9.3) - '@prisma/extension-accelerate@2.0.2(@prisma/client@6.19.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3))': + '@prisma/fetch-engine@7.0.0': dependencies: - '@prisma/client': 6.19.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3) + '@prisma/debug': 7.0.0 + '@prisma/engines-version': 6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513 + '@prisma/get-platform': 7.0.0 - '@prisma/fetch-engine@6.17.0': + '@prisma/get-platform@6.8.2': dependencies: - '@prisma/debug': 6.17.0 - '@prisma/engines-version': 6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a - '@prisma/get-platform': 6.17.0 + '@prisma/debug': 6.8.2 - '@prisma/fetch-engine@6.19.0': + '@prisma/get-platform@7.0.0': dependencies: - '@prisma/debug': 6.19.0 - '@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 - '@prisma/get-platform': 6.19.0 + '@prisma/debug': 7.0.0 - '@prisma/get-platform@6.17.0': - dependencies: - '@prisma/debug': 6.17.0 + '@prisma/query-plan-executor@6.18.0': {} - '@prisma/get-platform@6.19.0': + '@prisma/studio-core-licensed@0.8.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@prisma/debug': 6.19.0 + '@types/react': 19.2.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.1.0)': dependencies: @@ -5560,6 +5717,8 @@ snapshots: asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} + axios@1.13.2: dependencies: follow-redirects: 1.15.11 @@ -5747,6 +5906,15 @@ snapshots: charenc@0.0.2: {} + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5866,6 +6034,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -5900,11 +6070,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - effect@3.16.12: - dependencies: - '@standard-schema/spec': 1.0.0 - fast-check: 3.23.2 - effect@3.18.4: dependencies: '@standard-schema/spec': 1.0.0 @@ -6085,6 +6250,10 @@ snapshots: - encoding - supports-color + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6104,6 +6273,8 @@ snapshots: get-package-type@0.1.0: {} + get-port-please@3.1.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -6164,6 +6335,8 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.11: {} + gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -6186,6 +6359,8 @@ snapshots: hls.js@1.6.13: {} + hono@4.7.10: {} + hono@4.9.10: {} html-encoding-sniffer@4.0.0: @@ -6201,6 +6376,8 @@ snapshots: transitivePeerDependencies: - supports-color + http-status-codes@2.3.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -6216,6 +6393,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} immediate@3.0.6: {} @@ -6252,6 +6433,8 @@ snapshots: is-promise@2.2.2: {} + is-property@1.0.2: {} + is-stream@2.0.1: {} isexe@2.0.0: {} @@ -6769,18 +6952,28 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lilconfig@2.1.0: {} + lines-and-columns@1.2.4: {} locate-path@5.0.0: dependencies: p-locate: 4.1.0 + lodash@4.17.21: {} + + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + + lru.min@1.1.3: {} + lucide-react@0.545.0(react@19.1.0): dependencies: react: 19.1.0 @@ -6871,6 +7064,22 @@ snapshots: ms@2.1.3: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.0 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.3 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.3: + dependencies: + lru-cache: 7.18.3 + nanoid@3.3.11: {} nanostores@1.0.1: {} @@ -7022,6 +7231,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.7: {} + potpack@1.0.2: {} pretty-format@27.5.1: @@ -7036,29 +7247,33 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - prisma@6.17.0(typescript@5.9.3): + prisma@7.0.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3): dependencies: - '@prisma/config': 6.17.0 - '@prisma/engines': 6.17.0 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - magicast - - prisma@6.19.0(typescript@5.9.3): - dependencies: - '@prisma/config': 6.19.0 - '@prisma/engines': 6.19.0 + '@prisma/config': 7.0.0 + '@prisma/dev': 0.13.0(typescript@5.9.3) + '@prisma/engines': 7.0.0 + '@prisma/studio-core-licensed': 0.8.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + mysql2: 3.15.3 + postgres: 3.4.7 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@types/react' - magicast + - react + - react-dom promise-worker-transferable@1.0.4: dependencies: is-promise: 2.2.2 lie: 3.3.0 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -7113,6 +7328,12 @@ snapshots: reflect-metadata@0.2.2: {} + regexp-to-ast@0.5.0: {} + + remeda@2.21.3: + dependencies: + type-fest: 4.41.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -7125,6 +7346,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + rou3@0.5.1: {} rrweb-cssom@0.8.0: {} @@ -7151,6 +7374,8 @@ snapshots: semver@7.7.3: {} + seq-queue@0.0.5: {} + set-cookie-parser@2.7.2: {} sharp@0.33.5: @@ -7266,6 +7491,8 @@ snapshots: sprintf-js@1.0.3: {} + sqlstring@2.3.3: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -7277,6 +7504,8 @@ snapshots: stats.js@0.17.0: {} + std-env@3.9.0: {} + stoppable@1.1.0: {} string-length@4.0.2: @@ -7487,6 +7716,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} ua-parser-js@0.7.41: {} @@ -7547,6 +7778,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.1.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -7676,6 +7911,10 @@ snapshots: cookie: 1.0.2 youch-core: 0.3.3 + zeptomatch@2.0.2: + dependencies: + grammex: 3.1.11 + zod@3.22.3: {} zod@4.1.12: {} diff --git a/prisma/prisma.config.ts b/prisma/prisma.config.ts new file mode 100644 index 0000000..72a17e2 --- /dev/null +++ b/prisma/prisma.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + url: env("DATABASE_URL"), + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 84caa14..25e8a58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,5 @@ datasource db { provider = "postgresql" - url = env("DATABASE_URL") } generator client { diff --git a/vercel.json b/vercel.json index cb4072a..b6cbb70 100644 --- a/vercel.json +++ b/vercel.json @@ -1,9 +1,9 @@ { - "buildCommand": "pnpx prisma generate --schema=./prisma/schema.prisma && cd frontend && pnpm run build", - "outputDirectory": "frontend/.next", - "installCommand": "pnpm install --frozen-lockfile", - "framework": null, - "env": { - "DATABASE_URL": "@database_url" - } + "buildCommand": "pnpx prisma generate --schema=./prisma/schema.prisma && cd frontend && pnpm run build", + "outputDirectory": "frontend/.next", + "installCommand": "pnpm install --frozen-lockfile", + "framework": null, + "env": { + "DATABASE_URL": "@database_url" + } }