mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 01:15:18 +08:00
Initial commit: OpenMAIC AI classroom platform
Next.js 16 App Router application with: - AI-powered interactive classroom generation from PDF/text requirements - Multi-agent discussion system (teacher, students, assistant roles) - Provider abstraction for LLM, TTS, ASR, image, video, web search - Local-first data architecture (IndexedDB/Dexie) - i18n support (zh-CN/en-US) - Structured logger, SSRF guard, lint/build passing (0 errors) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# server provider config (contains API keys)
|
||||
server-providers.yml
|
||||
server-providers-*.yml
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# generated data
|
||||
/data
|
||||
/logs
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"proseWrap": "preserve",
|
||||
"endOfLine": "lf",
|
||||
"embeddedLanguageFormatting": "auto"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
const log = createLogger('Azure Voices')
|
||||
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* Azure TTS Voice List API
|
||||
* Fetches available voices from Azure Speech Services
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { apiKey, baseUrl } = await req.json();
|
||||
|
||||
if (!apiKey) {
|
||||
return apiError('MISSING_API_KEY', 400, 'API Key is required');
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required');
|
||||
}
|
||||
|
||||
// Validate baseUrl against SSRF
|
||||
const ssrfError = validateUrlForSSRF(baseUrl);
|
||||
if (ssrfError) {
|
||||
return apiError('INVALID_URL', 403, ssrfError);
|
||||
}
|
||||
|
||||
// Call Azure voices list endpoint; disable redirect following to prevent SSRF via redirect
|
||||
const response = await fetch(`${baseUrl}/cognitiveservices/voices/list`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Ocp-Apim-Subscription-Key': apiKey,
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return apiError(
|
||||
'UPSTREAM_ERROR',
|
||||
response.status,
|
||||
'Failed to fetch voices from Azure',
|
||||
errorText || response.statusText,
|
||||
);
|
||||
}
|
||||
|
||||
const voices = await response.json();
|
||||
|
||||
return apiSuccess({ voices });
|
||||
} catch (error) {
|
||||
log.error('API error:', error);
|
||||
return apiError(
|
||||
'INTERNAL_ERROR',
|
||||
500,
|
||||
'Failed to fetch voices',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Stateless Chat API Endpoint
|
||||
*
|
||||
* POST /api/chat - Send message, receive SSE stream
|
||||
*
|
||||
* This endpoint:
|
||||
* 1. Receives full state from client (messages + storeState)
|
||||
* 2. Runs single-pass generation
|
||||
* 3. Streams events as SSE (text deltas + tool calls)
|
||||
*
|
||||
* Fully stateless: interruption is handled by the client aborting
|
||||
* the fetch request, which triggers req.signal on the server side.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { statelessGenerate } from '@/lib/orchestration/stateless-generate';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
|
||||
import type { ThinkingConfig } from '@/lib/types/provider';
|
||||
import { apiError } from '@/lib/server/api-response'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
const log = createLogger('Chat API')
|
||||
|
||||
|
||||
// Allow streaming responses up to 60 seconds
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Send a message and receive SSE stream of generation events
|
||||
*
|
||||
* Request body: StatelessChatRequest
|
||||
* {
|
||||
* messages: UIMessage[],
|
||||
* storeState: { stage, scenes, currentSceneId, mode },
|
||||
* config: { agentIds, sessionType? },
|
||||
* apiKey: string,
|
||||
* baseUrl?: string,
|
||||
* model?: string
|
||||
* }
|
||||
*
|
||||
* Response: SSE stream of StatelessEvent
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
try {
|
||||
const body: StatelessChatRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.messages || !Array.isArray(body.messages)) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages');
|
||||
}
|
||||
|
||||
if (!body.storeState) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState');
|
||||
}
|
||||
|
||||
if (!body.config || !body.config.agentIds || body.config.agentIds.length === 0) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds');
|
||||
}
|
||||
|
||||
// Resolve API key: client > server > empty
|
||||
const modelString = body.model || 'gpt-4o-mini';
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const effectiveApiKey = resolveApiKey(providerId, body.apiKey);
|
||||
const effectiveBaseUrl = resolveBaseUrl(providerId, body.baseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return apiError('MISSING_API_KEY', 401, 'API Key is required');
|
||||
}
|
||||
|
||||
log.info('Processing request');
|
||||
log.info(`Agents: ${body.config.agentIds.join(', ')}, Messages: ${body.messages.length}, Turn: ${body.directorState?.turnCount ?? 0}`);
|
||||
|
||||
// Create LanguageModel via the unified provider system
|
||||
const { model: languageModel } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey: effectiveApiKey,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
proxy,
|
||||
});
|
||||
|
||||
// Use the native request signal for abort propagation
|
||||
const signal = req.signal;
|
||||
|
||||
// Create SSE stream
|
||||
const { readable, writable } = new TransformStream();
|
||||
const writer = writable.getWriter();
|
||||
|
||||
// Stream generation in background with heartbeat to prevent connection timeout
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
(async () => {
|
||||
// Heartbeat: periodically send SSE comments to keep the connection alive.
|
||||
// Proxies / browsers may close idle SSE connections after 30-120s of silence.
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const startHeartbeat = () => {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat());
|
||||
} catch {
|
||||
stopHeartbeat();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
};
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
startHeartbeat();
|
||||
|
||||
const generator = statelessGenerate(
|
||||
{
|
||||
...body,
|
||||
apiKey: effectiveApiKey,
|
||||
},
|
||||
signal,
|
||||
languageModel,
|
||||
{ enabled: false } satisfies ThinkingConfig,
|
||||
);
|
||||
|
||||
for await (const event of generator) {
|
||||
if (signal.aborted) {
|
||||
log.info('Request was aborted');
|
||||
break;
|
||||
}
|
||||
|
||||
const data = `data: ${JSON.stringify(event)}\n\n`;
|
||||
await writer.write(encoder.encode(data));
|
||||
}
|
||||
|
||||
stopHeartbeat();
|
||||
await writer.close();
|
||||
} catch (error) {
|
||||
stopHeartbeat();
|
||||
|
||||
// If aborted, just close the writer silently
|
||||
if (signal.aborted) {
|
||||
log.info('Request aborted during streaming');
|
||||
try { await writer.close(); } catch { /* already closed */ }
|
||||
return;
|
||||
}
|
||||
|
||||
log.error('Stream error:', error);
|
||||
|
||||
// Try to send error event
|
||||
try {
|
||||
const errorEvent: StatelessEvent = {
|
||||
type: 'error',
|
||||
data: { message: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
await writer.write(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`));
|
||||
await writer.close();
|
||||
} catch {
|
||||
// Writer may already be closed
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed to process request');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getModel, parseModelString } from "@/lib/ai/providers";
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from "@/lib/server/provider-config";
|
||||
import { callLLM } from "@/lib/ai/llm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ParsedPdfContent } from "@/lib/types/pdf";
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response'
|
||||
const log = createLogger('Classroom')
|
||||
|
||||
|
||||
// Schema for the generated classroom structure
|
||||
const SlideSchema = z.object({
|
||||
title: z.string().describe("Slide title"),
|
||||
content: z.string().describe("Main text content for the slide"),
|
||||
keyPoints: z.array(z.string()).describe("Key bullet points"),
|
||||
imageIndex: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Index of image to use from PDF (0-based), if applicable"),
|
||||
});
|
||||
|
||||
const ClassroomSchema = z.object({
|
||||
title: z.string().describe("Course title"),
|
||||
description: z.string().describe("Course description"),
|
||||
slides: z.array(SlideSchema).describe("Generated slides"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
pdfContent,
|
||||
background,
|
||||
model,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerType,
|
||||
requiresApiKey,
|
||||
} = body as {
|
||||
pdfContent: ParsedPdfContent;
|
||||
background?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
providerType?: string;
|
||||
requiresApiKey?: boolean;
|
||||
};
|
||||
|
||||
if (!pdfContent || !pdfContent.text) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF content provided');
|
||||
}
|
||||
|
||||
const modelString = model || "gpt-4o-mini";
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const effectiveApiKey = resolveApiKey(providerId, apiKey);
|
||||
const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
log.info(`Using model: provider=${providerId}, model=${modelId}`);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey: effectiveApiKey || '',
|
||||
baseUrl: effectiveBaseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
const backgroundContext = background
|
||||
? `\n\nStudent background: ${background}\nPlease adapt the content complexity and examples based on this background.`
|
||||
: "";
|
||||
|
||||
const imagesContext =
|
||||
pdfContent.images.length > 0
|
||||
? `\n\nNote: This PDF contains ${pdfContent.images.length} images. You can reference them in slides by including "imageIndex" field (0-based index).`
|
||||
: "";
|
||||
|
||||
const systemMessage =
|
||||
"You are an expert educator. You MUST respond with valid JSON only, no additional text or markdown formatting.";
|
||||
const userMessage = `Create a presentation from the following PDF content.
|
||||
|
||||
PDF Content:
|
||||
${pdfContent.text}${backgroundContext}${imagesContext}
|
||||
|
||||
Generate a JSON object with this exact structure:
|
||||
{
|
||||
"title": "Course title",
|
||||
"description": "Course description",
|
||||
"slides": [
|
||||
{
|
||||
"title": "Slide title",
|
||||
"content": "Main text content for the slide",
|
||||
"keyPoints": ["Point 1", "Point 2", "Point 3"],
|
||||
"imageIndex": 0 // Optional: index of image to display (0-based), only include if relevant
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Requirements:
|
||||
1. Create an engaging title and description
|
||||
2. Generate 10-15 slides that cover the main topics
|
||||
3. Each slide should have a title, main content, and 3-5 key points
|
||||
4. Structure the content logically and pedagogically
|
||||
5. Use clear explanations with practical examples
|
||||
6. If images are available, use "imageIndex" to reference them in appropriate slides
|
||||
|
||||
Respond with ONLY the JSON object, no markdown code blocks or additional text.`;
|
||||
|
||||
log.debug('AI Generation', { systemMessage: systemMessage.substring(0, 100), userMessageLength: userMessage.length, background: background || '(none)', imagesAvailable: pdfContent.images.length })
|
||||
|
||||
const result = await callLLM({
|
||||
model: languageModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemMessage,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: userMessage,
|
||||
},
|
||||
],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
}, 'generate-classroom');
|
||||
|
||||
log.debug('AI Response', { length: result.text.length, preview: result.text.substring(0, 200) })
|
||||
|
||||
// Parse the JSON response
|
||||
let outputData: z.infer<typeof ClassroomSchema>;
|
||||
try {
|
||||
const jsonText = result.text
|
||||
.replace(/```json\n?/g, "")
|
||||
.replace(/```\n?/g, "")
|
||||
.trim();
|
||||
outputData = ClassroomSchema.parse(JSON.parse(jsonText));
|
||||
|
||||
log.debug('Parsed data', { title: outputData.title, slides: outputData.slides.length })
|
||||
} catch (parseError) {
|
||||
log.error('Failed to parse AI response', parseError)
|
||||
throw new Error("Failed to parse AI-generated content");
|
||||
}
|
||||
|
||||
// Transform the result into the Stage format
|
||||
const stageId = nanoid(10);
|
||||
const scenes = outputData.slides.map((slide, index) => ({
|
||||
id: `scene_${nanoid(10)}`,
|
||||
type: "slide" as const,
|
||||
title: slide.title,
|
||||
transcript: slide.content,
|
||||
order: index,
|
||||
content: {
|
||||
type: "slide" as const,
|
||||
canvas: {
|
||||
id: `slide_${nanoid(10)}`,
|
||||
viewportSize: 1000,
|
||||
viewportRatio: 0.5625,
|
||||
theme: {
|
||||
themeColors: ["#5b9bd5", "#70ad47", "#ffc000", "#ed7d31"],
|
||||
fontColor: "#333333",
|
||||
fontName: "Microsoft YaHei",
|
||||
backgroundColor: "#ffffff",
|
||||
outline: {
|
||||
width: 0,
|
||||
color: "#000000",
|
||||
style: "solid" as const,
|
||||
},
|
||||
shadow: {
|
||||
h: 0,
|
||||
v: 0,
|
||||
blur: 0,
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
// Title element
|
||||
{
|
||||
type: "text" as const,
|
||||
id: `text_${nanoid(10)}`,
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 800,
|
||||
height: 80,
|
||||
rotate: 0,
|
||||
content: `<p style="text-align: center;"><span style="font-size: 36px; color: #5b9bd5;"><strong>${slide.title}</strong></span></p>`,
|
||||
defaultFontName: "Microsoft YaHei",
|
||||
defaultColor: "#5b9bd5",
|
||||
lineHeight: 1.2,
|
||||
opacity: 1,
|
||||
},
|
||||
// Image element (if imageIndex is provided and valid)
|
||||
...(slide.imageIndex !== undefined &&
|
||||
slide.imageIndex >= 0 &&
|
||||
slide.imageIndex < pdfContent.images.length
|
||||
? [
|
||||
{
|
||||
type: "image" as const,
|
||||
id: `image_${nanoid(10)}`,
|
||||
left: 550,
|
||||
top: 220,
|
||||
width: 350,
|
||||
height: 350,
|
||||
rotate: 0,
|
||||
src: pdfContent.images[slide.imageIndex],
|
||||
fixedRatio: true,
|
||||
opacity: 1,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Content element
|
||||
{
|
||||
type: "text" as const,
|
||||
id: `text_${nanoid(10)}`,
|
||||
left: 100,
|
||||
top: 220,
|
||||
width: slide.imageIndex !== undefined ? 400 : 800,
|
||||
height: 400,
|
||||
rotate: 0,
|
||||
content: `<p>${slide.content}</p><ul>${slide.keyPoints.map((point) => `<li>${point}</li>`).join("")}</ul>`,
|
||||
defaultFontName: "Microsoft YaHei",
|
||||
defaultColor: "#333333",
|
||||
lineHeight: 1.8,
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
background: {
|
||||
type: "solid" as const,
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const stage = {
|
||||
id: stageId,
|
||||
name: outputData.title,
|
||||
description: outputData.description,
|
||||
language: "zh-CN",
|
||||
style: "interactive",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
log.debug('Final output', { stageId: stage.id, stageName: stage.name, scenesCount: scenes.length })
|
||||
|
||||
return apiSuccess({ stage, scenes });
|
||||
} catch (error) {
|
||||
log.error('Error generating classroom:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, 'Failed to generate classroom', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Agent Profiles Generation API
|
||||
*
|
||||
* Generates agent profiles (teacher, assistant, student) for a course stage
|
||||
* based on stage info and scene outlines.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('Agent Profiles API');
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#06b6d4',
|
||||
'#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#6366f1',
|
||||
'#84cc16', '#a855f7',
|
||||
];
|
||||
|
||||
interface RequestBody {
|
||||
stageInfo: { name: string; description?: string };
|
||||
sceneOutlines?: { title: string; description?: string }[];
|
||||
language: string;
|
||||
availableAvatars: string[];
|
||||
}
|
||||
|
||||
function stripCodeFences(text: string): string {
|
||||
let cleaned = text.trim();
|
||||
// Remove markdown code fences (```json ... ``` or ``` ... ```)
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as RequestBody;
|
||||
const { stageInfo, sceneOutlines, language, availableAvatars } = body;
|
||||
|
||||
// ── Validate required fields ──
|
||||
if (!stageInfo?.name) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageInfo.name is required');
|
||||
}
|
||||
if (!language) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'language is required');
|
||||
}
|
||||
if (!availableAvatars || availableAvatars.length === 0) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'availableAvatars is required and must not be empty');
|
||||
}
|
||||
|
||||
// ── Model resolution from request headers ──
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// ── Build prompt ──
|
||||
const sceneSummary = sceneOutlines?.length
|
||||
? sceneOutlines
|
||||
.map((s, i) => `${i + 1}. ${s.title}${s.description ? ` — ${s.description}` : ''}`)
|
||||
.join('\n')
|
||||
: null;
|
||||
|
||||
const systemPrompt = `You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Decide the appropriate number of agents (typically 3-5) based on the course content and complexity. Return ONLY valid JSON, no markdown or explanation.`;
|
||||
|
||||
const userPrompt = `Generate agent profiles for the following course:
|
||||
|
||||
Course name: ${stageInfo.name}
|
||||
${stageInfo.description ? `Course description: ${stageInfo.description}` : ''}
|
||||
${sceneSummary ? `\nScene outlines:\n${sceneSummary}\n` : ''}
|
||||
Requirements:
|
||||
- Decide the appropriate number of agents based on the course content (typically 3-5)
|
||||
- Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student"
|
||||
- Priority values: teacher=10 (highest), assistant=7, student=4-6
|
||||
- Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style)
|
||||
- Names and personas must be in language: ${language}
|
||||
- Each agent must be assigned one avatar from this list: ${JSON.stringify(availableAvatars)}
|
||||
- Try to use different avatars for each agent
|
||||
- Each agent must be assigned one color from this list: ${JSON.stringify(COLOR_PALETTE)}
|
||||
- Each agent must have a different color
|
||||
|
||||
Return a JSON object with this exact structure:
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "string",
|
||||
"role": "teacher" | "assistant" | "student",
|
||||
"persona": "string (2-3 sentences)",
|
||||
"avatar": "string (from available list)",
|
||||
"color": "string (hex color from palette)",
|
||||
"priority": number (10 for teacher, 7 for assistant, 4-6 for student)
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
log.info(`Generating agent profiles for "${stageInfo.name}" [model=${modelString}]`);
|
||||
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
},
|
||||
'agent-profiles',
|
||||
);
|
||||
|
||||
// ── Parse LLM response ──
|
||||
const rawText = stripCodeFences(result.text);
|
||||
let parsed: { agents: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
persona: string;
|
||||
avatar: string;
|
||||
color: string;
|
||||
priority: number;
|
||||
}> };
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawText);
|
||||
} catch {
|
||||
log.error('Failed to parse LLM response as JSON:', rawText.substring(0, 500));
|
||||
return apiError('PARSE_FAILED', 500, 'Failed to parse agent profiles from LLM response');
|
||||
}
|
||||
|
||||
// ── Validate parsed structure ──
|
||||
if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) {
|
||||
log.error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`);
|
||||
return apiError('GENERATION_FAILED', 500, `Expected at least 2 agents but LLM returned ${parsed.agents?.length ?? 0}`);
|
||||
}
|
||||
|
||||
const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length;
|
||||
if (teacherCount !== 1) {
|
||||
log.error(`Expected exactly 1 teacher, got ${teacherCount}`);
|
||||
return apiError('GENERATION_FAILED', 500, `Expected exactly 1 teacher but LLM returned ${teacherCount}`);
|
||||
}
|
||||
|
||||
// ── Build output with IDs ──
|
||||
const agents = parsed.agents.map((agent, index) => ({
|
||||
id: `gen-${nanoid(8)}`,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
persona: agent.persona,
|
||||
avatar: agent.avatar || availableAvatars[index % availableAvatars.length],
|
||||
color: agent.color || COLOR_PALETTE[index % COLOR_PALETTE.length],
|
||||
priority: agent.priority ?? (agent.role === 'teacher' ? 10 : agent.role === 'assistant' ? 7 : 5),
|
||||
}));
|
||||
|
||||
log.info(`Successfully generated ${agents.length} agent profiles for "${stageInfo.name}"`);
|
||||
|
||||
return apiSuccess({ agents });
|
||||
} catch (error) {
|
||||
log.error('Agent profiles generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Image Generation API
|
||||
*
|
||||
* Generates an image from a text prompt using the specified provider.
|
||||
* Called by the client during media generation after slides are produced.
|
||||
*
|
||||
* POST /api/generate/image
|
||||
*
|
||||
* Headers:
|
||||
* x-image-provider: ImageProviderId (default: 'seedream')
|
||||
* x-api-key: string (optional, server fallback)
|
||||
* x-base-url: string (optional, server fallback)
|
||||
*
|
||||
* Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? }
|
||||
* Response: { success: boolean, result?: ImageGenerationResult, error?: string }
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateImage, aspectRatioToDimensions } from '@/lib/media/image-providers';
|
||||
import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('ImageGeneration API');
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json() as ImageGenerationOptions;
|
||||
|
||||
if (!body.prompt) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
|
||||
}
|
||||
|
||||
const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId;
|
||||
const clientApiKey = request.headers.get('x-api-key') || undefined;
|
||||
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
|
||||
const clientModel = request.headers.get('x-image-model') || undefined;
|
||||
|
||||
const apiKey = resolveImageApiKey(providerId, clientApiKey);
|
||||
if (!apiKey) {
|
||||
return apiError('MISSING_API_KEY', 401, `No API key configured for image provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
|
||||
|
||||
// Resolve dimensions from aspect ratio if not explicitly set
|
||||
if (!body.width && !body.height && body.aspectRatio) {
|
||||
const dims = aspectRatioToDimensions(body.aspectRatio);
|
||||
body.width = dims.width;
|
||||
body.height = dims.height;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` +
|
||||
`prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`,
|
||||
);
|
||||
|
||||
const result = await generateImage(
|
||||
{ providerId, apiKey, baseUrl, model: clientModel },
|
||||
body,
|
||||
);
|
||||
|
||||
return apiSuccess({ result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected)
|
||||
if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
|
||||
log.warn(`Image blocked by content safety filter: ${message}`);
|
||||
return apiError('CONTENT_SENSITIVE', 400, message);
|
||||
}
|
||||
log.error('Image generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Generation API Route
|
||||
*
|
||||
* Triggers the three-stage generation pipeline
|
||||
*/
|
||||
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
runGenerationPipeline,
|
||||
createGenerationSession,
|
||||
type GenerationCallbacks,
|
||||
} from '@/lib/generation/generation-pipeline';
|
||||
import type { UserRequirements } from '@/lib/types/generation';
|
||||
import type { Stage, Scene } from '@/lib/types/stage';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response'
|
||||
const log = createLogger('Generate')
|
||||
|
||||
|
||||
// Create a mock store adapter for server-side generation
|
||||
function createMockStore(initialStage: Stage, initialScenes: Scene[]) {
|
||||
let state: {
|
||||
stage: Stage | null;
|
||||
scenes: Scene[];
|
||||
currentSceneId: string | null;
|
||||
mode: 'autonomous' | 'playback';
|
||||
} = {
|
||||
stage: initialStage,
|
||||
scenes: initialScenes,
|
||||
currentSceneId: initialScenes[0]?.id || null,
|
||||
mode: 'autonomous',
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: (newState: Partial<typeof state>) => {
|
||||
state = { ...state, ...newState };
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { requirements, stage, scenes } = body as {
|
||||
requirements: UserRequirements;
|
||||
stage: Stage;
|
||||
scenes: Scene[];
|
||||
};
|
||||
|
||||
// Get API configuration from request headers
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
// Parse model string and get configured model instance
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// AI call function for the pipeline
|
||||
const aiCall = async (systemPrompt: string, userPrompt: string): Promise<string> => {
|
||||
const result = await callLLM({
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
}, 'generate-pipeline');
|
||||
return result.text;
|
||||
};
|
||||
|
||||
// Create mock store
|
||||
const mockStore = createMockStore(stage, scenes);
|
||||
|
||||
// Create session
|
||||
const session = createGenerationSession(requirements);
|
||||
|
||||
// Track progress
|
||||
const progressLog: string[] = [];
|
||||
const callbacks: GenerationCallbacks = {
|
||||
onProgress: (progress) => {
|
||||
progressLog.push(`[Stage ${progress.currentStage}] ${progress.statusMessage} (${progress.overallProgress}%)`);
|
||||
},
|
||||
onStageComplete: (stageNum, _result) => {
|
||||
progressLog.push(`Stage ${stageNum} completed`);
|
||||
},
|
||||
onError: (error) => {
|
||||
progressLog.push(`Error: ${error}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Run pipeline
|
||||
const result = await runGenerationPipeline(session, mockStore, aiCall, callbacks);
|
||||
|
||||
if (!result.success) {
|
||||
return apiError('GENERATION_FAILED', 500, result.error || 'Generation failed', JSON.stringify(progressLog));
|
||||
}
|
||||
|
||||
// Return generated scenes
|
||||
return apiSuccess({ data: { session: result.data, scenes: mockStore.getState().scenes, progressLog } });
|
||||
} catch (error) {
|
||||
log.error('Generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Scene Actions Generation API
|
||||
*
|
||||
* Generates actions for a scene given its outline and content,
|
||||
* then assembles the complete Scene object.
|
||||
* This is the second half of the two-step scene generation pipeline.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
generateSceneActions,
|
||||
buildCompleteScene,
|
||||
buildVisionUserContent,
|
||||
type SceneGenerationContext,
|
||||
type AgentInfo,
|
||||
} from '@/lib/generation/generation-pipeline';
|
||||
import type { SceneOutline } from '@/lib/types/generation';
|
||||
import type {
|
||||
GeneratedSlideContent,
|
||||
GeneratedQuizContent,
|
||||
GeneratedInteractiveContent,
|
||||
GeneratedPBLContent,
|
||||
} from '@/lib/types/generation';
|
||||
import type { SpeechAction } from '@/lib/types/action';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('Scene Actions API');
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
outline,
|
||||
allOutlines,
|
||||
content,
|
||||
stageId,
|
||||
agents,
|
||||
previousSpeeches: incomingPreviousSpeeches,
|
||||
userProfile,
|
||||
} = body as {
|
||||
outline: SceneOutline;
|
||||
allOutlines: SceneOutline[];
|
||||
content: GeneratedSlideContent | GeneratedQuizContent | GeneratedInteractiveContent | GeneratedPBLContent;
|
||||
stageId: string;
|
||||
agents?: AgentInfo[];
|
||||
previousSpeeches?: string[];
|
||||
userProfile?: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!outline) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
|
||||
}
|
||||
if (!allOutlines || allOutlines.length === 0) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty');
|
||||
}
|
||||
if (!content) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'content is required');
|
||||
}
|
||||
if (!stageId) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
|
||||
}
|
||||
|
||||
// ── Model resolution from request headers ──
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
// AI call function (actions typically don't use vision, but kept for consistency)
|
||||
const aiCall = async (
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
images?: Array<{ id: string; src: string }>,
|
||||
): Promise<string> => {
|
||||
if (images?.length && hasVision) {
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: 'user' as const, content: buildVisionUserContent(userPrompt, images) },
|
||||
],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene-actions',
|
||||
);
|
||||
return result.text;
|
||||
}
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene-actions',
|
||||
);
|
||||
return result.text;
|
||||
};
|
||||
|
||||
// ── Build cross-scene context ──
|
||||
const allTitles = allOutlines.map((o) => o.title);
|
||||
const pageIndex = allOutlines.findIndex((o) => o.id === outline.id);
|
||||
const ctx: SceneGenerationContext = {
|
||||
pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1,
|
||||
totalPages: allOutlines.length,
|
||||
allTitles,
|
||||
previousSpeeches: incomingPreviousSpeeches ?? [],
|
||||
};
|
||||
|
||||
// ── Generate actions ──
|
||||
log.info(`Generating actions: "${outline.title}" (${outline.type}) [model=${modelString}]`);
|
||||
|
||||
const actions = await generateSceneActions(
|
||||
outline,
|
||||
content,
|
||||
aiCall,
|
||||
ctx,
|
||||
agents,
|
||||
userProfile,
|
||||
);
|
||||
|
||||
log.info(`Generated ${actions.length} actions for: "${outline.title}"`);
|
||||
|
||||
// ── Build complete scene ──
|
||||
const scene = buildCompleteScene(outline, content, actions, stageId);
|
||||
|
||||
if (!scene) {
|
||||
log.error(`Failed to build scene: "${outline.title}"`);
|
||||
|
||||
return apiError('GENERATION_FAILED', 500, `Failed to build scene: ${outline.title}`);
|
||||
}
|
||||
|
||||
// ── Extract speeches for cross-scene coherence ──
|
||||
const outputPreviousSpeeches = (scene.actions || [])
|
||||
.filter((a): a is SpeechAction => a.type === 'speech')
|
||||
.map((a) => a.text);
|
||||
|
||||
log.info(
|
||||
`Scene assembled successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`,
|
||||
);
|
||||
|
||||
return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches });
|
||||
} catch (error) {
|
||||
log.error('Scene actions generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Scene Content Generation API
|
||||
*
|
||||
* Generates scene content (slides/quiz/interactive/pbl) from an outline.
|
||||
* This is the first half of the two-step scene generation pipeline.
|
||||
* Does NOT generate actions — use /api/generate/scene-actions for that.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
applyOutlineFallbacks,
|
||||
generateSceneContent,
|
||||
buildVisionUserContent,
|
||||
} from '@/lib/generation/generation-pipeline';
|
||||
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
|
||||
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('Scene Content API');
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
outline: rawOutline,
|
||||
allOutlines,
|
||||
pdfImages,
|
||||
imageMapping,
|
||||
stageInfo,
|
||||
stageId,
|
||||
agents,
|
||||
} = body as {
|
||||
outline: SceneOutline;
|
||||
allOutlines: SceneOutline[];
|
||||
pdfImages?: PdfImage[];
|
||||
imageMapping?: ImageMapping;
|
||||
stageInfo: {
|
||||
name: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
style?: string;
|
||||
};
|
||||
stageId: string;
|
||||
agents?: AgentInfo[];
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!rawOutline) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
|
||||
}
|
||||
if (!allOutlines || allOutlines.length === 0) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty');
|
||||
}
|
||||
if (!stageId) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
|
||||
}
|
||||
|
||||
// Ensure outline has language from stageInfo (fallback for older outlines)
|
||||
const outline: SceneOutline = {
|
||||
...rawOutline,
|
||||
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
|
||||
};
|
||||
|
||||
// ── Model resolution from request headers ──
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
// Vision-aware AI call function
|
||||
const aiCall = async (
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
images?: Array<{ id: string; src: string }>,
|
||||
): Promise<string> => {
|
||||
if (images?.length && hasVision) {
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: 'user' as const, content: buildVisionUserContent(userPrompt, images) },
|
||||
],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene-content',
|
||||
);
|
||||
return result.text;
|
||||
}
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene-content',
|
||||
);
|
||||
return result.text;
|
||||
};
|
||||
|
||||
// ── Apply fallbacks ──
|
||||
const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel);
|
||||
|
||||
// ── Filter images assigned to this outline ──
|
||||
let assignedImages: PdfImage[] | undefined;
|
||||
if (
|
||||
pdfImages &&
|
||||
pdfImages.length > 0 &&
|
||||
effectiveOutline.suggestedImageIds &&
|
||||
effectiveOutline.suggestedImageIds.length > 0
|
||||
) {
|
||||
const suggestedIds = new Set(effectiveOutline.suggestedImageIds);
|
||||
assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id));
|
||||
}
|
||||
|
||||
// ── Media generation is handled client-side in parallel (media-orchestrator.ts) ──
|
||||
// The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is.
|
||||
// resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements.
|
||||
const generatedMediaMapping: ImageMapping = {};
|
||||
|
||||
// ── Generate content ──
|
||||
log.info(`Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`);
|
||||
|
||||
const content = await generateSceneContent(
|
||||
effectiveOutline,
|
||||
aiCall,
|
||||
assignedImages,
|
||||
imageMapping,
|
||||
effectiveOutline.type === 'pbl' ? languageModel : undefined,
|
||||
hasVision,
|
||||
generatedMediaMapping,
|
||||
agents,
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
log.error(`Failed to generate content for: "${effectiveOutline.title}"`);
|
||||
|
||||
return apiError('GENERATION_FAILED', 500, `Failed to generate content: ${effectiveOutline.title}`);
|
||||
}
|
||||
|
||||
log.info(`Content generated successfully: "${effectiveOutline.title}"`);
|
||||
|
||||
return apiSuccess({ content, effectiveOutline });
|
||||
} catch (error) {
|
||||
log.error('Scene content generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Scene Outlines Streaming API (SSE)
|
||||
*
|
||||
* Streams outline generation via Server-Sent Events.
|
||||
* Emits individual outline objects as they're parsed from the LLM response,
|
||||
* so the frontend can display them incrementally.
|
||||
*
|
||||
* SSE events:
|
||||
* { type: 'outline', data: SceneOutline, index: number }
|
||||
* { type: 'done', outlines: SceneOutline[] }
|
||||
* { type: 'error', error: string }
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { streamLLM } from '@/lib/ai/llm';
|
||||
import { buildPrompt, PROMPT_IDS } from '@/lib/generation/prompts';
|
||||
import { formatImageDescription, formatImagePlaceholder, buildVisionUserContent, uniquifyMediaElementIds, formatTeacherPersonaForPrompt } from '@/lib/generation/generation-pipeline';
|
||||
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
|
||||
import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { UserRequirements, PdfImage, SceneOutline, ImageMapping } from '@/lib/types/generation';
|
||||
import { apiError } from '@/lib/server/api-response'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
const log = createLogger('Outlines Stream')
|
||||
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Incremental JSON array parser.
|
||||
* Extracts complete top-level objects from a partially-streamed JSON array.
|
||||
* Returns newly found objects (skipping `alreadyParsed` count).
|
||||
*/
|
||||
function extractNewOutlines(buffer: string, alreadyParsed: number): SceneOutline[] {
|
||||
const results: SceneOutline[] = [];
|
||||
|
||||
// Find the start of the JSON array (skip any markdown fencing)
|
||||
const stripped = buffer.replace(/^[\s\S]*?(?=\[)/, '');
|
||||
const arrayStart = stripped.indexOf('[');
|
||||
if (arrayStart === -1) return results;
|
||||
|
||||
let depth = 0;
|
||||
let objectStart = -1;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let objectCount = 0;
|
||||
|
||||
for (let i = arrayStart + 1; i < stripped.length; i++) {
|
||||
const char = stripped[i];
|
||||
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (char === '\\' && inString) { escaped = true; continue; }
|
||||
if (char === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
|
||||
if (char === '{') {
|
||||
if (depth === 0) objectStart = i;
|
||||
depth++;
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && objectStart >= 0) {
|
||||
objectCount++;
|
||||
if (objectCount > alreadyParsed) {
|
||||
try {
|
||||
const obj = JSON.parse(stripped.substring(objectStart, i + 1));
|
||||
results.push(obj);
|
||||
} catch {
|
||||
// Incomplete or invalid JSON — skip
|
||||
}
|
||||
}
|
||||
objectStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// Get API configuration from request headers
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
if (!body.requirements) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required');
|
||||
}
|
||||
|
||||
const { requirements, pdfText, pdfImages, imageMapping, researchContext, agents } = body as {
|
||||
requirements: UserRequirements;
|
||||
pdfText?: string;
|
||||
pdfImages?: PdfImage[];
|
||||
imageMapping?: ImageMapping;
|
||||
researchContext?: string;
|
||||
agents?: AgentInfo[];
|
||||
};
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
// Build prompt (same logic as generateSceneOutlinesFromRequirements)
|
||||
let availableImagesText = requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
|
||||
let visionImages: Array<{ id: string; src: string }> | undefined;
|
||||
|
||||
if (pdfImages && pdfImages.length > 0) {
|
||||
if (hasVision && imageMapping) {
|
||||
// Vision mode: split into vision images (first N) and text-only (rest)
|
||||
const allWithSrc = pdfImages.filter(img => imageMapping[img.id]);
|
||||
const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES);
|
||||
const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
|
||||
const noSrcImages = pdfImages.filter(img => !imageMapping[img.id]);
|
||||
|
||||
const visionDescriptions = visionSlice.map(img => formatImagePlaceholder(img, requirements.language));
|
||||
const textDescriptions = [...textOnlySlice, ...noSrcImages].map(img => formatImageDescription(img, requirements.language));
|
||||
availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
|
||||
|
||||
visionImages = visionSlice.map(img => ({ id: img.id, src: imageMapping[img.id], width: img.width, height: img.height }));
|
||||
} else {
|
||||
// Text-only mode: full descriptions
|
||||
availableImagesText = pdfImages.map(img =>
|
||||
formatImageDescription(img, requirements.language)
|
||||
).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Build media generation policy based on enabled flags
|
||||
const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true';
|
||||
const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true';
|
||||
let mediaGenerationPolicy = '';
|
||||
if (!imageGenerationEnabled && !videoGenerationEnabled) {
|
||||
mediaGenerationPolicy = '**IMPORTANT: Do NOT include any mediaGenerations in the outlines. Both image and video generation are disabled.**';
|
||||
} else if (!imageGenerationEnabled) {
|
||||
mediaGenerationPolicy = '**IMPORTANT: Do NOT include any image mediaGenerations (type: "image") in the outlines. Image generation is disabled. Video generation is allowed.**';
|
||||
} else if (!videoGenerationEnabled) {
|
||||
mediaGenerationPolicy = '**IMPORTANT: Do NOT include any video mediaGenerations (type: "video") in the outlines. Video generation is disabled. Image generation is allowed.**';
|
||||
}
|
||||
|
||||
// Build teacher context from agents (if available)
|
||||
const teacherContext = formatTeacherPersonaForPrompt(agents);
|
||||
|
||||
const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
|
||||
requirement: requirements.requirement,
|
||||
language: requirements.language,
|
||||
pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : (requirements.language === 'zh-CN' ? '无' : 'None'),
|
||||
availableImages: availableImagesText,
|
||||
researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
|
||||
mediaGenerationPolicy,
|
||||
teacherContext,
|
||||
});
|
||||
|
||||
if (!prompts) {
|
||||
return apiError('INTERNAL_ERROR', 500, 'Prompt template not found');
|
||||
}
|
||||
|
||||
log.info(`Generating outlines: "${requirements.requirement.substring(0, 50)}" [model=${modelString}]`);
|
||||
|
||||
// Create SSE stream with heartbeat to prevent connection timeout
|
||||
const encoder = new TextEncoder();
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Heartbeat: periodically send SSE comments to keep the connection alive.
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const startHeartbeat = () => {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`:heartbeat\n\n`));
|
||||
} catch {
|
||||
stopHeartbeat();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
};
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_STREAM_RETRIES = 2;
|
||||
|
||||
try {
|
||||
startHeartbeat();
|
||||
|
||||
const streamParams = (visionImages?.length)
|
||||
? {
|
||||
model: languageModel,
|
||||
system: prompts.system,
|
||||
messages: [{ role: 'user' as const, content: buildVisionUserContent(prompts.user, visionImages) }],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
}
|
||||
: {
|
||||
model: languageModel,
|
||||
system: prompts.system,
|
||||
prompt: prompts.user,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
};
|
||||
|
||||
let parsedOutlines: SceneOutline[] = [];
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) {
|
||||
try {
|
||||
const result = streamLLM(streamParams, 'scene-outlines-stream');
|
||||
|
||||
let fullText = '';
|
||||
parsedOutlines = [];
|
||||
|
||||
for await (const chunk of result.textStream) {
|
||||
fullText += chunk;
|
||||
|
||||
// Try to extract new outlines from the accumulated text
|
||||
const newOutlines = extractNewOutlines(fullText, parsedOutlines.length);
|
||||
for (const outline of newOutlines) {
|
||||
// Ensure ID and order
|
||||
const enriched = {
|
||||
...outline,
|
||||
id: outline.id || nanoid(),
|
||||
order: parsedOutlines.length + 1,
|
||||
};
|
||||
parsedOutlines.push(enriched);
|
||||
|
||||
const event = JSON.stringify({
|
||||
type: 'outline',
|
||||
data: enriched,
|
||||
index: parsedOutlines.length - 1,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${event}\n\n`));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: got outlines?
|
||||
if (parsedOutlines.length > 0) break;
|
||||
|
||||
// Empty result — retry if we have attempts left
|
||||
lastError = fullText.trim()
|
||||
? 'LLM response could not be parsed into outlines'
|
||||
: 'LLM returned empty response';
|
||||
|
||||
if (attempt <= MAX_STREAM_RETRIES) {
|
||||
log.warn(`Empty outlines (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`);
|
||||
// Notify client a retry is happening
|
||||
const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 });
|
||||
controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`));
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (attempt <= MAX_STREAM_RETRIES) {
|
||||
log.warn(`Stream error (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`, error);
|
||||
const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1 });
|
||||
controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedOutlines.length > 0) {
|
||||
// Replace sequential gen_img_N/gen_vid_N with globally unique IDs
|
||||
const uniquifiedOutlines = uniquifyMediaElementIds(parsedOutlines);
|
||||
// Send done event with all outlines
|
||||
const doneEvent = JSON.stringify({
|
||||
type: 'done',
|
||||
outlines: uniquifiedOutlines,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`));
|
||||
} else {
|
||||
// All retries exhausted, no outlines produced
|
||||
log.error(`Outline generation failed after ${MAX_STREAM_RETRIES + 1} attempts: ${lastError}`);
|
||||
const errorEvent = JSON.stringify({
|
||||
type: 'error',
|
||||
error: lastError || 'Failed to generate outlines',
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorEvent = JSON.stringify({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`));
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Streaming error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Scene Outlines Generation API
|
||||
*
|
||||
* Generates scene outlines directly from user requirements.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateText } from 'ai';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { generateSceneOutlinesFromRequirements } from '@/lib/generation/generation-pipeline';
|
||||
import type { UserRequirements, PdfImage } from '@/lib/types/generation';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response'
|
||||
const log = createLogger('Outlines')
|
||||
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// Get API configuration from request headers
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
// Parse model string and resolve server-side fallback
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// AI call function for the pipeline
|
||||
const aiCall = async (systemPrompt: string, userPrompt: string): Promise<string> => {
|
||||
const result = await generateText({
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
});
|
||||
return result.text;
|
||||
};
|
||||
|
||||
// Validate request body
|
||||
if (!body.requirements) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required');
|
||||
}
|
||||
|
||||
const { requirements, pdfText, pdfImages } = body as {
|
||||
requirements: UserRequirements;
|
||||
pdfText?: string;
|
||||
pdfImages?: PdfImage[];
|
||||
};
|
||||
|
||||
const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true';
|
||||
const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true';
|
||||
|
||||
const result = await generateSceneOutlinesFromRequirements(requirements, pdfText, pdfImages, aiCall, undefined, {
|
||||
imageGenerationEnabled,
|
||||
videoGenerationEnabled,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return apiError('GENERATION_FAILED', 500, result.error || 'Failed to generate scene outlines');
|
||||
}
|
||||
|
||||
return apiSuccess({ outlines: result.data });
|
||||
} catch (error) {
|
||||
log.error('Generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Single-Scene Generation API (JSON)
|
||||
*
|
||||
* Generates one scene at a time from a given outline.
|
||||
* No streaming, no TTS — just scene content generation.
|
||||
* The client calls this once per scene and handles orchestration.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
buildSceneFromOutline,
|
||||
buildVisionUserContent,
|
||||
type SceneGenerationContext,
|
||||
type AgentInfo,
|
||||
} from '@/lib/generation/generation-pipeline';
|
||||
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
|
||||
import type { SpeechAction } from '@/lib/types/action';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('Scene API');
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const {
|
||||
outline,
|
||||
allOutlines,
|
||||
pdfImages,
|
||||
imageMapping,
|
||||
stageInfo,
|
||||
stageId,
|
||||
agents,
|
||||
previousSpeeches: incomingPreviousSpeeches,
|
||||
userProfile,
|
||||
} = body as {
|
||||
outline: SceneOutline;
|
||||
allOutlines: SceneOutline[];
|
||||
pdfImages?: PdfImage[];
|
||||
imageMapping?: ImageMapping;
|
||||
stageInfo: {
|
||||
name: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
style?: string;
|
||||
};
|
||||
stageId: string;
|
||||
agents?: AgentInfo[];
|
||||
previousSpeeches?: string[];
|
||||
userProfile?: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!outline) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
|
||||
}
|
||||
if (!allOutlines || allOutlines.length === 0) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'allOutlines is required and must not be empty');
|
||||
}
|
||||
if (!stageId) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
|
||||
}
|
||||
|
||||
// Ensure outline has language from stageInfo (fallback for older outlines)
|
||||
if (!outline.language) {
|
||||
outline.language = (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN';
|
||||
}
|
||||
|
||||
// ── Model resolution from request headers ──
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
// Vision-aware AI call function for the pipeline
|
||||
const aiCall = async (
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
images?: Array<{ id: string; src: string }>,
|
||||
): Promise<string> => {
|
||||
if (images?.length && hasVision) {
|
||||
// Multimodal: use messages format with image content parts
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: 'user' as const, content: buildVisionUserContent(userPrompt, images) },
|
||||
],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene',
|
||||
);
|
||||
return result.text;
|
||||
}
|
||||
// Text-only fallback
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
},
|
||||
'scene',
|
||||
);
|
||||
return result.text;
|
||||
};
|
||||
|
||||
// ── Build cross-scene context ──
|
||||
const allTitles = allOutlines.map((o) => o.title);
|
||||
const pageIndex = allOutlines.findIndex((o) => o.id === outline.id);
|
||||
const ctx: SceneGenerationContext = {
|
||||
pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1,
|
||||
totalPages: allOutlines.length,
|
||||
allTitles,
|
||||
previousSpeeches: incomingPreviousSpeeches ?? [],
|
||||
};
|
||||
|
||||
// ── Filter images assigned to this outline ──
|
||||
let assignedImages: PdfImage[] | undefined;
|
||||
if (
|
||||
pdfImages &&
|
||||
pdfImages.length > 0 &&
|
||||
outline.suggestedImageIds &&
|
||||
outline.suggestedImageIds.length > 0
|
||||
) {
|
||||
const suggestedIds = new Set(outline.suggestedImageIds);
|
||||
assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id));
|
||||
}
|
||||
|
||||
// ── Generate the scene ──
|
||||
log.info(`Generating scene: "${outline.title}" (${outline.type}) [model=${modelString}]`);
|
||||
|
||||
const scene = await buildSceneFromOutline(
|
||||
outline,
|
||||
aiCall,
|
||||
stageId,
|
||||
assignedImages,
|
||||
imageMapping,
|
||||
outline.type === 'pbl' ? languageModel : undefined,
|
||||
hasVision,
|
||||
ctx,
|
||||
agents,
|
||||
undefined, // onPhaseChange — not needed for non-streaming
|
||||
userProfile,
|
||||
);
|
||||
|
||||
if (!scene) {
|
||||
log.error(`Failed to generate scene: "${outline.title}"`);
|
||||
|
||||
return apiError('GENERATION_FAILED', 500, `Failed to generate scene: ${outline.title}`);
|
||||
}
|
||||
|
||||
// ── Extract speeches for cross-scene coherence ──
|
||||
const outputPreviousSpeeches = (scene.actions || [])
|
||||
.filter((a): a is SpeechAction => a.type === 'speech')
|
||||
.map((a) => a.text);
|
||||
|
||||
log.info(
|
||||
`Scene generated successfully: "${outline.title}" — ${scene.actions?.length ?? 0} actions`,
|
||||
);
|
||||
|
||||
return apiSuccess({ scene, previousSpeeches: outputPreviousSpeeches });
|
||||
} catch (error) {
|
||||
log.error('Scene generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Single TTS Generation API
|
||||
*
|
||||
* Generates TTS audio for a single text string and returns base64-encoded audio.
|
||||
* Called by the client in parallel for each speech action after a scene is generated.
|
||||
*
|
||||
* POST /api/generate/tts
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateTTS } from '@/lib/audio/tts-providers';
|
||||
import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { TTSProviderId } from '@/lib/audio/types';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('TTS API');
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { text, audioId, ttsProviderId, ttsVoice, ttsSpeed, ttsApiKey, ttsBaseUrl } = body as {
|
||||
text: string;
|
||||
audioId: string;
|
||||
ttsProviderId: TTSProviderId;
|
||||
ttsVoice: string;
|
||||
ttsSpeed?: number;
|
||||
ttsApiKey?: string;
|
||||
ttsBaseUrl?: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!text || !audioId || !ttsProviderId || !ttsVoice) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required fields: text, audioId, ttsProviderId, ttsVoice');
|
||||
}
|
||||
|
||||
// Reject browser-native TTS — must be handled client-side
|
||||
if (ttsProviderId === 'browser-native-tts') {
|
||||
return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side');
|
||||
}
|
||||
|
||||
// Resolve API key and base URL (server-side fallback)
|
||||
const apiKey = resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined);
|
||||
const baseUrl = resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined);
|
||||
|
||||
// Build TTS config
|
||||
const config = {
|
||||
providerId: ttsProviderId,
|
||||
voice: ttsVoice,
|
||||
speed: ttsSpeed ?? 1.0,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
log.info(`Generating TTS: provider=${ttsProviderId}, voice=${ttsVoice}, audioId=${audioId}, textLen=${text.length}`);
|
||||
|
||||
// Generate audio
|
||||
const { audio, format } = await generateTTS(config, text);
|
||||
|
||||
// Convert to base64
|
||||
const base64 = Buffer.from(audio).toString('base64');
|
||||
|
||||
return apiSuccess({ audioId, base64, format });
|
||||
} catch (error) {
|
||||
log.error('TTS generation error:', error);
|
||||
return apiError('GENERATION_FAILED', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Video Generation API
|
||||
*
|
||||
* Generates a video from a text prompt using the specified provider.
|
||||
* Uses async task pattern (submit → poll) so maxDuration is set to 5 minutes.
|
||||
*
|
||||
* POST /api/generate/video
|
||||
*
|
||||
* Headers:
|
||||
* x-video-provider: VideoProviderId (default: 'seedance')
|
||||
* x-video-model: string (optional model override)
|
||||
* x-api-key: string (optional, server fallback)
|
||||
* x-base-url: string (optional, server fallback)
|
||||
*
|
||||
* Body: { prompt, duration?, aspectRatio?, resolution? }
|
||||
* Response: { success: boolean, result?: VideoGenerationResult, error?: string }
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers';
|
||||
import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('VideoGeneration API');
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json() as VideoGenerationOptions;
|
||||
|
||||
if (!body.prompt) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
|
||||
}
|
||||
|
||||
const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId;
|
||||
const clientApiKey = request.headers.get('x-api-key') || undefined;
|
||||
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
|
||||
const clientModel = request.headers.get('x-video-model') || undefined;
|
||||
|
||||
const apiKey = resolveVideoApiKey(providerId, clientApiKey);
|
||||
if (!apiKey) {
|
||||
return apiError('MISSING_API_KEY', 401, `No API key configured for video provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl);
|
||||
|
||||
// Normalize options against provider capabilities
|
||||
const options = normalizeVideoOptions(providerId, body);
|
||||
|
||||
log.info(
|
||||
`Generating video: provider=${providerId}, model=${clientModel || 'default'}, ` +
|
||||
`prompt="${body.prompt.slice(0, 80)}...", duration=${options.duration ?? 'auto'}, ` +
|
||||
`aspect=${options.aspectRatio ?? 'auto'}, resolution=${options.resolution ?? 'auto'}`,
|
||||
);
|
||||
|
||||
const result = await generateVideo(
|
||||
{ providerId, apiKey, baseUrl, model: clientModel },
|
||||
options,
|
||||
);
|
||||
|
||||
log.info(`Video generated: url=${result.url ? 'yes' : 'no'}, ${result.width}x${result.height}, ${result.duration}s`);
|
||||
|
||||
return apiSuccess({ result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Detect content safety filter rejections (e.g. Seedance SensitiveContent errors)
|
||||
if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
|
||||
log.warn(`Video blocked by content safety filter: ${message}`);
|
||||
return apiError('CONTENT_SENSITIVE', 400, message);
|
||||
}
|
||||
log.error('Video generation error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { parsePDF } from "@/lib/pdf/pdf-providers";
|
||||
import { resolvePDFApiKey, resolvePDFBaseUrl } from "@/lib/server/provider-config";
|
||||
import type { PDFProviderId } from "@/lib/pdf/types";
|
||||
import type { ParsedPdfContent } from "@/lib/types/pdf";
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
const log = createLogger('Parse PDF')
|
||||
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
log.error('Invalid Content-Type for PDF upload:', contentType);
|
||||
return apiError(
|
||||
'INVALID_REQUEST',
|
||||
400,
|
||||
`Invalid Content-Type: expected multipart/form-data, got "${contentType}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const pdfFile = formData.get("pdf") as File | null;
|
||||
const providerId = formData.get("providerId") as PDFProviderId | null;
|
||||
const apiKey = formData.get("apiKey") as string | null;
|
||||
const baseUrl = formData.get("baseUrl") as string | null;
|
||||
|
||||
if (!pdfFile) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF file provided');
|
||||
}
|
||||
|
||||
// providerId is required from the client — no server-side store to fall back to
|
||||
const effectiveProviderId = providerId || ('unpdf' as PDFProviderId);
|
||||
|
||||
const config = {
|
||||
providerId: effectiveProviderId,
|
||||
apiKey: resolvePDFApiKey(effectiveProviderId, apiKey || undefined),
|
||||
baseUrl: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined),
|
||||
};
|
||||
|
||||
// Convert PDF to buffer
|
||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Parse PDF using the provider system
|
||||
const result = await parsePDF(config, buffer);
|
||||
|
||||
// Add file metadata
|
||||
const resultWithMetadata: ParsedPdfContent = {
|
||||
...result,
|
||||
metadata: {
|
||||
pageCount: result.metadata?.pageCount || 0, // Ensure pageCount is always a number
|
||||
...result.metadata,
|
||||
fileName: pdfFile.name,
|
||||
fileSize: pdfFile.size,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess({ data: resultWithMetadata });
|
||||
} catch (error) {
|
||||
log.error('Error parsing PDF:', error);
|
||||
return apiError(
|
||||
'PARSE_FAILED',
|
||||
500,
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* PBL Runtime Chat API
|
||||
*
|
||||
* Handles @mention routing during PBL runtime.
|
||||
* Students @question or @judge an agent, and this endpoint generates a response.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers'
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'
|
||||
import { callLLM } from '@/lib/ai/llm'
|
||||
import type { PBLAgent, PBLIssue } from '@/lib/pbl/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response'
|
||||
const log = createLogger('PBL Chat')
|
||||
|
||||
|
||||
interface PBLChatRequest {
|
||||
message: string
|
||||
agent: PBLAgent
|
||||
currentIssue: PBLIssue | null
|
||||
recentMessages: { agent_name: string; message: string }[]
|
||||
userRole: string
|
||||
agentType?: 'question' | 'judge'
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as PBLChatRequest
|
||||
const { message, agent, currentIssue, recentMessages, userRole, agentType } = body
|
||||
|
||||
if (!message || !agent) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Message and agent are required');
|
||||
}
|
||||
|
||||
// Get model config from headers (same pattern as scene-stream)
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini'
|
||||
const clientApiKey = req.headers.get('x-api-key') || ''
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined
|
||||
const providerType = req.headers.get('x-provider-type') || undefined
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true'
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString)
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey)
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl)
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
})
|
||||
|
||||
// Build context for the agent, differentiating question vs judge
|
||||
let issueContext = ''
|
||||
if (currentIssue) {
|
||||
issueContext = `\n\n## Current Issue\nTitle: ${currentIssue.title}\nDescription: ${currentIssue.description}\nPerson in Charge: ${currentIssue.person_in_charge}`
|
||||
if (currentIssue.generated_questions) {
|
||||
if (agentType === 'judge') {
|
||||
issueContext += `\n\nQuestions to Evaluate Against:\n${currentIssue.generated_questions}`
|
||||
} else {
|
||||
issueContext += `\n\nGenerated Questions:\n${currentIssue.generated_questions}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recentContext = recentMessages.length > 0
|
||||
? `\n\n## Recent Conversation\n${recentMessages.slice(-5).map(m => `${m.agent_name}: ${m.message}`).join('\n')}`
|
||||
: ''
|
||||
|
||||
const systemPrompt = `${agent.system_prompt}${issueContext}${recentContext}${userRole ? `\n\nThe student's role is: ${userRole}` : ''}`
|
||||
|
||||
const result = await callLLM({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: message,
|
||||
}, 'pbl-chat')
|
||||
|
||||
return apiSuccess({ message: result.text, agentName: agent.name });
|
||||
} catch (error) {
|
||||
log.error('Error:', error)
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Media Proxy API
|
||||
*
|
||||
* Server-side proxy for fetching remote media URLs (images/videos).
|
||||
* Required because browser fetch() to remote CDN URLs fails with CORS errors.
|
||||
* The media orchestrator uses this to download generated media as blobs
|
||||
* for IndexedDB persistence.
|
||||
*
|
||||
* POST /api/proxy-media
|
||||
* Body: { url: string }
|
||||
* Response: Binary blob with appropriate Content-Type
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
||||
import { apiError } from '@/lib/server/api-response';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('ProxyMedia');
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url');
|
||||
}
|
||||
|
||||
// Block local/private network URLs to prevent SSRF
|
||||
const ssrfError = validateUrlForSSRF(url);
|
||||
if (ssrfError) {
|
||||
return apiError('INVALID_URL', 403, ssrfError);
|
||||
}
|
||||
|
||||
// Disable redirect following to prevent redirect-to-internal attacks
|
||||
const response = await fetch(url, { redirect: 'manual' });
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
|
||||
}
|
||||
if (!response.ok) {
|
||||
return apiError('UPSTREAM_ERROR', 502, `Upstream returned ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
|
||||
return new NextResponse(blob, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(blob.size),
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Proxy media error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Quiz Grading API
|
||||
*
|
||||
* POST: Receives a text question + user answer, calls LLM for scoring and feedback.
|
||||
* Used for short-answer (text) questions that cannot be graded locally.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
const log = createLogger('Quiz Grade')
|
||||
|
||||
|
||||
interface GradeRequest {
|
||||
question: string;
|
||||
userAnswer: string;
|
||||
points: number;
|
||||
commentPrompt?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
interface GradeResponse {
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as GradeRequest;
|
||||
const { question, userAnswer, points, commentPrompt, language } = body;
|
||||
|
||||
if (!question || !userAnswer) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'question and userAnswer are required');
|
||||
}
|
||||
|
||||
// Resolve model from request headers (same pattern as scene-stream)
|
||||
const modelString = req.headers.get('x-model') || 'gpt-4o-mini';
|
||||
const clientApiKey = req.headers.get('x-api-key') || '';
|
||||
const clientBaseUrl = req.headers.get('x-base-url') || undefined;
|
||||
const providerType = req.headers.get('x-provider-type') || undefined;
|
||||
const requiresApiKey = req.headers.get('x-requires-api-key') === 'true';
|
||||
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model: languageModel } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
|
||||
const isZh = language === 'zh-CN';
|
||||
|
||||
const systemPrompt = isZh
|
||||
? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。
|
||||
必须以如下 JSON 格式回复(不要包含其他内容):
|
||||
{"score": <0到${points}的整数>, "comment": "<一两句评语>"}`
|
||||
: `You are a professional educational assessor. Grade the student's answer and provide brief feedback.
|
||||
You must reply in the following JSON format only (no other content):
|
||||
{"score": <integer from 0 to ${points}>, "comment": "<one or two sentences of feedback>"}`;
|
||||
|
||||
const userPrompt = isZh
|
||||
? `题目:${question}
|
||||
满分:${points}分
|
||||
${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}`
|
||||
: `Question: ${question}
|
||||
Full marks: ${points} points
|
||||
${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`;
|
||||
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
},
|
||||
'quiz-grade',
|
||||
);
|
||||
|
||||
// Parse the LLM response as JSON
|
||||
const text = result.text.trim();
|
||||
let gradeResult: GradeResponse;
|
||||
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('No JSON found');
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
gradeResult = {
|
||||
score: Math.max(0, Math.min(points, Math.round(Number(parsed.score)))),
|
||||
comment: String(parsed.comment || ''),
|
||||
};
|
||||
} catch {
|
||||
// Fallback: give partial credit with a generic comment
|
||||
gradeResult = {
|
||||
score: Math.round(points * 0.5),
|
||||
comment: isZh ? '已作答,请参考标准答案。' : 'Answer received. Please refer to the standard answer.',
|
||||
};
|
||||
}
|
||||
|
||||
return apiSuccess({ ...gradeResult });
|
||||
} catch (error) {
|
||||
log.error('Error:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, 'Failed to grade answer');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
getServerProviders,
|
||||
getServerTTSProviders,
|
||||
getServerASRProviders,
|
||||
getServerPDFProviders,
|
||||
getServerImageProviders,
|
||||
getServerVideoProviders,
|
||||
getServerWebSearchProviders,
|
||||
} from '@/lib/server/provider-config';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('ServerProviders');
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return apiSuccess({
|
||||
providers: getServerProviders(),
|
||||
tts: getServerTTSProviders(),
|
||||
asr: getServerASRProviders(),
|
||||
pdf: getServerPDFProviders(),
|
||||
image: getServerImageProviders(),
|
||||
video: getServerVideoProviders(),
|
||||
webSearch: getServerWebSearchProviders(),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error fetching server providers:', error);
|
||||
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { transcribeAudio } from '@/lib/audio/asr-providers';
|
||||
import { resolveASRApiKey, resolveASRBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { ASRProviderId } from '@/lib/audio/types';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
const log = createLogger('Transcription')
|
||||
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const audioFile = formData.get('audio') as File;
|
||||
const providerId = formData.get('providerId') as ASRProviderId | null;
|
||||
const language = formData.get('language') as string | null;
|
||||
const apiKey = formData.get('apiKey') as string | null;
|
||||
const baseUrl = formData.get('baseUrl') as string | null;
|
||||
|
||||
if (!audioFile) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Audio file is required');
|
||||
}
|
||||
|
||||
// providerId is required from the client — no server-side store to fall back to
|
||||
const effectiveProviderId = providerId || ('openai-whisper' as ASRProviderId);
|
||||
|
||||
const config = {
|
||||
providerId: effectiveProviderId,
|
||||
language: language || 'auto',
|
||||
apiKey: resolveASRApiKey(effectiveProviderId, apiKey || undefined),
|
||||
baseUrl: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined),
|
||||
};
|
||||
|
||||
// Convert audio file to buffer
|
||||
const arrayBuffer = await audioFile.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Transcribe using the provider system
|
||||
const result = await transcribeAudio(config, buffer);
|
||||
|
||||
return apiSuccess({ text: result.text });
|
||||
} catch (error) {
|
||||
log.error('Transcription error:', error);
|
||||
return apiError(
|
||||
'TRANSCRIPTION_FAILED',
|
||||
500,
|
||||
'Transcription failed',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Verify Image Provider API
|
||||
*
|
||||
* Lightweight endpoint that validates provider credentials without generating images.
|
||||
*
|
||||
* POST /api/verify-image-provider
|
||||
*
|
||||
* Headers:
|
||||
* x-image-provider: ImageProviderId
|
||||
* x-image-model: string (optional)
|
||||
* x-api-key: string (optional, server fallback)
|
||||
* x-base-url: string (optional, server fallback)
|
||||
*
|
||||
* Response: { success: boolean, message: string }
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { testImageConnectivity } from '@/lib/media/image-providers';
|
||||
import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { ImageProviderId } from '@/lib/media/types';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('VerifyImageProvider');
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId;
|
||||
const model = request.headers.get('x-image-model') || undefined;
|
||||
const clientApiKey = request.headers.get('x-api-key') || undefined;
|
||||
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
|
||||
|
||||
const apiKey = resolveImageApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
|
||||
|
||||
if (!apiKey) {
|
||||
return apiError('MISSING_API_KEY', 400, 'No API key configured');
|
||||
}
|
||||
|
||||
const result = await testImageConnectivity({
|
||||
providerId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return apiError('UPSTREAM_ERROR', 500, result.message);
|
||||
}
|
||||
|
||||
return apiSuccess({ message: result.message });
|
||||
} catch (err) {
|
||||
log.error('Connectivity test error:', err);
|
||||
return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
import { generateText } from 'ai';
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
const log = createLogger('Verify Model')
|
||||
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { apiKey, baseUrl, model, providerType, requiresApiKey } = await req.json();
|
||||
|
||||
if (!model) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Model name is required');
|
||||
}
|
||||
|
||||
// Parse model string and resolve server-side fallback
|
||||
const { providerId, modelId } = parseModelString(model);
|
||||
const effectiveApiKey = resolveApiKey(providerId, apiKey);
|
||||
const effectiveBaseUrl = resolveBaseUrl(providerId, baseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
let languageModel;
|
||||
try {
|
||||
const result = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey: effectiveApiKey || "",
|
||||
baseUrl: effectiveBaseUrl || undefined,
|
||||
proxy,
|
||||
providerType: providerType as "openai" | "anthropic" | "google" | undefined,
|
||||
requiresApiKey,
|
||||
});
|
||||
languageModel = result.model;
|
||||
} catch (error) {
|
||||
return apiError('INVALID_REQUEST', 401, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
// Send a minimal test message
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt: 'Say "OK" if you can hear me.',
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
message: 'Connection successful',
|
||||
response: text,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('API test error:', error);
|
||||
|
||||
let errorMessage = 'Connection failed';
|
||||
if (error instanceof Error) {
|
||||
// Parse common error messages
|
||||
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
||||
errorMessage = 'API key is invalid or expired';
|
||||
} else if (error.message.includes('404') || error.message.includes('not found')) {
|
||||
errorMessage = 'Model not found or API endpoint error';
|
||||
} else if (error.message.includes('429')) {
|
||||
errorMessage = 'API rate limit exceeded, please try again later';
|
||||
} else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'Cannot connect to API server, please check the Base URL';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = 'Connection timed out, please check your network';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
return apiError('INTERNAL_ERROR', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Verify Video Provider API
|
||||
*
|
||||
* Lightweight endpoint that validates provider credentials without generating video.
|
||||
*
|
||||
* POST /api/verify-video-provider
|
||||
*
|
||||
* Headers:
|
||||
* x-video-provider: VideoProviderId
|
||||
* x-video-model: string (optional)
|
||||
* x-api-key: string (optional, server fallback)
|
||||
* x-base-url: string (optional, server fallback)
|
||||
*
|
||||
* Response: { success: boolean, message: string }
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { testVideoConnectivity } from '@/lib/media/video-providers';
|
||||
import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config';
|
||||
import type { VideoProviderId } from '@/lib/media/types';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('VerifyVideoProvider');
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId;
|
||||
const model = request.headers.get('x-video-model') || undefined;
|
||||
const clientApiKey = request.headers.get('x-api-key') || undefined;
|
||||
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
|
||||
|
||||
const apiKey = resolveVideoApiKey(providerId, clientApiKey);
|
||||
const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl);
|
||||
|
||||
if (!apiKey) {
|
||||
return apiError('MISSING_API_KEY', 400, 'No API key configured');
|
||||
}
|
||||
|
||||
const result = await testVideoConnectivity({
|
||||
providerId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return apiError('UPSTREAM_ERROR', 500, result.message);
|
||||
}
|
||||
|
||||
return apiSuccess({ message: result.message });
|
||||
} catch (err) {
|
||||
log.error('Connectivity test error:', err);
|
||||
return apiError('INTERNAL_ERROR', 500, `Connectivity test error: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Web Search API
|
||||
*
|
||||
* POST /api/web-search
|
||||
* Simple JSON request/response using Tavily search.
|
||||
*/
|
||||
|
||||
import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
|
||||
import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
|
||||
const log = createLogger('WebSearch');
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { query, apiKey: clientApiKey } = body as {
|
||||
query?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
|
||||
}
|
||||
|
||||
const apiKey = resolveWebSearchApiKey(clientApiKey);
|
||||
if (!apiKey) {
|
||||
return apiError(
|
||||
'MISSING_API_KEY',
|
||||
400,
|
||||
'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await searchWithTavily({ query: query.trim(), apiKey });
|
||||
const context = formatSearchResultsAsContext(result);
|
||||
|
||||
return apiSuccess({
|
||||
answer: result.answer,
|
||||
sources: result.sources,
|
||||
context,
|
||||
query: result.query,
|
||||
responseTime: result.responseTime,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('[WebSearch] Error:', err);
|
||||
const message = err instanceof Error ? err.message : 'Web search failed';
|
||||
return apiError('INTERNAL_ERROR', 500, message);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { Stage } from '@/components/stage';
|
||||
import { ThemeProvider } from '@/lib/use-theme';
|
||||
import { useStageStore } from '@/lib/store';
|
||||
import { loadImageMapping } from '@/lib/utils/image-storage';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
|
||||
import { useMediaGenerationStore } from '@/lib/store/media-generation';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { MediaStageProvider } from '@/lib/store/media-stage-context';
|
||||
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
|
||||
|
||||
const log = createLogger('Classroom');
|
||||
|
||||
export default function ClassroomDetailPage() {
|
||||
const params = useParams();
|
||||
const classroomId = params?.id as string;
|
||||
|
||||
const { loadFromStorage } = useStageStore();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generationStartedRef = useRef(false);
|
||||
|
||||
const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
|
||||
onComplete: () => {
|
||||
log.info('[Classroom] All scenes generated');
|
||||
},
|
||||
});
|
||||
|
||||
const loadClassroom = useCallback(async () => {
|
||||
try {
|
||||
await loadFromStorage(classroomId);
|
||||
// Restore completed media generation tasks from IndexedDB
|
||||
await useMediaGenerationStore.getState().restoreFromDB(classroomId);
|
||||
// Restore generated agents for this stage
|
||||
const { loadGeneratedAgentsForStage } = await import('@/lib/orchestration/registry/store');
|
||||
const agentIds = await loadGeneratedAgentsForStage(classroomId);
|
||||
if (agentIds.length > 0) {
|
||||
const { useSettingsStore } = await import('@/lib/store/settings');
|
||||
useSettingsStore.getState().setSelectedAgentIds(agentIds);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to load classroom:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load classroom');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classroomId, loadFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loading state on course switch to unmount Stage during transition,
|
||||
// preventing stale data from syncing back to the new course
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
generationStartedRef.current = false;
|
||||
|
||||
// Clear previous classroom's media tasks to prevent cross-classroom contamination.
|
||||
// Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages,
|
||||
// so stale tasks from a previous classroom would shadow the new one's.
|
||||
const mediaStore = useMediaGenerationStore.getState();
|
||||
mediaStore.revokeObjectUrls();
|
||||
useMediaGenerationStore.setState({ tasks: {} });
|
||||
|
||||
loadClassroom();
|
||||
|
||||
// Cancel ongoing generation when classroomId changes or component unmounts
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [classroomId, loadClassroom, stop]);
|
||||
|
||||
// Auto-resume generation for pending outlines
|
||||
useEffect(() => {
|
||||
if (loading || error || generationStartedRef.current) return;
|
||||
|
||||
const state = useStageStore.getState();
|
||||
const { outlines, scenes, stage } = state;
|
||||
|
||||
// Check if there are pending outlines
|
||||
const completedOrders = new Set(scenes.map(s => s.order));
|
||||
const hasPending = outlines.some(o => !completedOrders.has(o.order));
|
||||
|
||||
if (hasPending && stage) {
|
||||
generationStartedRef.current = true;
|
||||
|
||||
// Load generation params from sessionStorage (stored by generation-preview before navigating)
|
||||
const genParamsStr = sessionStorage.getItem('generationParams');
|
||||
const params = genParamsStr ? JSON.parse(genParamsStr) : {};
|
||||
|
||||
// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
|
||||
const storageIds = (params.pdfImages || [])
|
||||
.map((img: { storageId?: string }) => img.storageId)
|
||||
.filter(Boolean);
|
||||
|
||||
loadImageMapping(storageIds).then((imageMapping) => {
|
||||
generateRemaining({
|
||||
pdfImages: params.pdfImages,
|
||||
imageMapping,
|
||||
stageInfo: {
|
||||
name: stage.name || '',
|
||||
description: stage.description,
|
||||
language: stage.language,
|
||||
style: stage.style,
|
||||
},
|
||||
agents: params.agents,
|
||||
userProfile: params.userProfile,
|
||||
});
|
||||
});
|
||||
} else if (outlines.length > 0 && stage) {
|
||||
// All scenes are generated, but some media may not have finished.
|
||||
// Resume media generation for any tasks not yet in IndexedDB.
|
||||
// generateMediaForOutlines skips already-completed tasks automatically.
|
||||
generationStartedRef.current = true;
|
||||
generateMediaForOutlines(outlines, stage.id).catch((err) => {
|
||||
log.warn('[Classroom] Media generation resume error:', err);
|
||||
});
|
||||
}
|
||||
}, [loading, error, generateRemaining]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<MediaStageProvider value={classroomId}>
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Loading classroom...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive mb-4">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
loadClassroom();
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Stage onRetryOutline={retrySingleOutline} />
|
||||
)}
|
||||
</div>
|
||||
</MediaStageProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,10 @@
|
||||
// Force dynamic rendering since this page uses client-side hooks (useI18n)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function GenerationPreviewLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+209
@@ -0,0 +1,209 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #722ed1;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #8b47ea;
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
/* ProseMirror Editor Styles */
|
||||
.prosemirror-editor {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.prosemirror-editor.format-painter {
|
||||
cursor: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEySDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+') 2 5, default !important;
|
||||
}
|
||||
|
||||
/* Setup-needed indicator animations */
|
||||
@keyframes setup-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4), 0 0 8px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(139, 92, 246, 0), 0 0 16px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes setup-ping {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
75%, 100% { transform: scale(2.2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes setup-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
.animate-setup-glow {
|
||||
animation: setup-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-setup-ping {
|
||||
animation: setup-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-setup-float {
|
||||
animation: setup-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes setup-border-breathe {
|
||||
0%, 100% {
|
||||
border-color: rgba(139, 92, 246, 0.25) !important;
|
||||
box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.05), inset 0 0 0 0 rgba(139, 92, 246, 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(139, 92, 246, 0.7) !important;
|
||||
box-shadow: 0 0 16px 0 rgba(139, 92, 246, 0.1), inset 0 0 12px 0 rgba(139, 92, 246, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-setup-border {
|
||||
animation: setup-border-breathe 2.5s ease-in-out infinite;
|
||||
border-style: solid !important;
|
||||
}
|
||||
|
||||
/* Animation for audio visualizer */
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
height: 30%;
|
||||
}
|
||||
50% {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar by default, show on hover */
|
||||
@utility scrollbar-hide {
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for skeleton loading */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import "animate.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { ThemeProvider } from "@/lib/use-theme";
|
||||
import { I18nProvider } from "@/lib/use-i18n";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ServerProvidersInit } from "@/components/server-providers-init";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenMAIC",
|
||||
description:
|
||||
"The open-source AI interactive classroom. Upload a PDF to instantly generate an immersive, multi-agent learning experience.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ServerProvidersInit />
|
||||
{children}
|
||||
<Toaster position="top-center" />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+978
@@ -0,0 +1,978 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
ImagePlus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
BotOff,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea as UITextarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsDialog } from '@/components/settings';
|
||||
import { GenerationToolbar } from '@/components/generation-toolbar';
|
||||
import { AgentBar } from '@/components/agent-bar';
|
||||
import { useTheme } from '@/lib/use-theme';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { storePdfBlob } from '@/lib/utils/image-storage';
|
||||
import type { UserRequirements } from '@/lib/types/generation';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
|
||||
import { StageListItem, listStages, deleteStageData, getFirstSlideByStages } from '@/lib/utils/stage-storage';
|
||||
import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide';
|
||||
import type { Slide } from '@/lib/types/slides';
|
||||
import { useMediaGenerationStore } from '@/lib/store/media-generation';
|
||||
import { toast } from 'sonner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
|
||||
import { SpeechButton } from '@/components/speech-button';
|
||||
|
||||
const log = createLogger('Home');
|
||||
|
||||
const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
|
||||
const LANGUAGE_STORAGE_KEY = 'generationLanguage';
|
||||
const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
|
||||
|
||||
interface FormState {
|
||||
pdfFile: File | null;
|
||||
requirement: string;
|
||||
language: 'zh-CN' | 'en-US';
|
||||
webSearch: boolean;
|
||||
}
|
||||
|
||||
const initialFormState: FormState = {
|
||||
pdfFile: null,
|
||||
requirement: '',
|
||||
language: 'zh-CN',
|
||||
webSearch: false,
|
||||
};
|
||||
|
||||
function HomePage() {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<FormState>(initialFormState);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<import('@/lib/types/settings').SettingsSection | undefined>(undefined);
|
||||
|
||||
// Draft cache for requirement text
|
||||
const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = useDraftCache<string>({ key: 'requirementDraft' });
|
||||
|
||||
// Model setup state
|
||||
const currentModelId = useSettingsStore((s) => s.modelId);
|
||||
const [storeHydrated, setStoreHydrated] = useState(false);
|
||||
const [recentOpen, setRecentOpen] = useState(true);
|
||||
|
||||
// Hydrate client-only state after mount (avoids SSR mismatch)
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */
|
||||
useEffect(() => {
|
||||
setStoreHydrated(true);
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY);
|
||||
if (saved !== null) setRecentOpen(saved !== 'false');
|
||||
} catch { /* localStorage unavailable */ }
|
||||
try {
|
||||
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
|
||||
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
const updates: Partial<FormState> = {};
|
||||
if (savedWebSearch === 'true') updates.webSearch = true;
|
||||
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
|
||||
updates.language = savedLanguage;
|
||||
} else {
|
||||
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||||
updates.language = detected;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Restore requirement draft from cache (derived state pattern — no effect needed)
|
||||
const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement);
|
||||
if (cachedRequirement !== prevCachedRequirement) {
|
||||
setPrevCachedRequirement(cachedRequirement);
|
||||
if (cachedRequirement) {
|
||||
setForm((prev) => ({ ...prev, requirement: cachedRequirement }));
|
||||
}
|
||||
}
|
||||
|
||||
const needsSetup = storeHydrated && !currentModelId;
|
||||
const [languageOpen, setLanguageOpen] = useState(false);
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [classrooms, setClassrooms] = useState<StageListItem[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, Slide>>({});
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
if (!languageOpen && !themeOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
|
||||
setLanguageOpen(false);
|
||||
setThemeOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [languageOpen, themeOpen]);
|
||||
|
||||
const loadClassrooms = async () => {
|
||||
try {
|
||||
const list = await listStages();
|
||||
setClassrooms(list);
|
||||
// Load first slide thumbnails
|
||||
if (list.length > 0) {
|
||||
const slides = await getFirstSlideByStages(list.map((c) => c.id));
|
||||
setThumbnails(slides);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to load classrooms:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Clear stale media store to prevent cross-course thumbnail contamination.
|
||||
// The store may hold tasks from a previously visited classroom whose elementIds
|
||||
// (gen_img_1, etc.) collide with other courses' placeholders.
|
||||
useMediaGenerationStore.getState().revokeObjectUrls();
|
||||
useMediaGenerationStore.setState({ tasks: {} });
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount
|
||||
loadClassrooms();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteStageData(id);
|
||||
toast.success('Classroom deleted');
|
||||
await loadClassrooms();
|
||||
} catch (err) {
|
||||
log.error('Failed to delete classroom:', err);
|
||||
toast.error('Failed to delete classroom');
|
||||
}
|
||||
};
|
||||
|
||||
const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
try {
|
||||
if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value));
|
||||
if (field === 'language') localStorage.setItem(LANGUAGE_STORAGE_KEY, String(value));
|
||||
if (field === 'requirement') updateRequirementCache(value as string);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => {
|
||||
toast.custom((id) => (
|
||||
<div
|
||||
className="w-[356px] rounded-xl border border-amber-200/60 dark:border-amber-800/40 bg-gradient-to-r from-amber-50 via-white to-amber-50 dark:from-amber-950/60 dark:via-slate-900 dark:to-amber-950/60 shadow-lg shadow-amber-500/8 dark:shadow-amber-900/20 p-4 flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => { toast.dismiss(id); setSettingsOpen(true); }}
|
||||
>
|
||||
<div className="shrink-0 mt-0.5 size-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-800/30">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200 leading-tight">{title}</p>
|
||||
<p className="text-xs text-amber-700/80 dark:text-amber-400/70 mt-0.5 leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
<div className="shrink-0 mt-1 text-[10px] font-medium text-amber-500 dark:text-amber-500/70 tracking-wide">
|
||||
<Settings className="size-3.5 animate-[spin_3s_linear_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
), { duration: 4000 });
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
// Validate setup before proceeding
|
||||
if (!currentModelId) {
|
||||
showSetupToast(
|
||||
<BotOff className="size-4.5 text-amber-600 dark:text-amber-400" />,
|
||||
t('settings.modelNotConfigured'),
|
||||
t('settings.setupNeeded'),
|
||||
);
|
||||
setSettingsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.requirement.trim()) {
|
||||
setError(t('upload.requirementRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const userProfile = useUserProfileStore.getState();
|
||||
const requirements: UserRequirements = {
|
||||
requirement: form.requirement,
|
||||
language: form.language,
|
||||
userNickname: userProfile.nickname || undefined,
|
||||
userBio: userProfile.bio || undefined,
|
||||
webSearch: form.webSearch || undefined,
|
||||
};
|
||||
|
||||
let pdfStorageKey: string | undefined;
|
||||
let pdfFileName: string | undefined;
|
||||
let pdfProviderId: string | undefined;
|
||||
let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined;
|
||||
|
||||
if (form.pdfFile) {
|
||||
pdfStorageKey = await storePdfBlob(form.pdfFile);
|
||||
pdfFileName = form.pdfFile.name;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
pdfProviderId = settings.pdfProviderId;
|
||||
const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId];
|
||||
if (providerCfg) {
|
||||
pdfProviderConfig = {
|
||||
apiKey: providerCfg.apiKey,
|
||||
baseUrl: providerCfg.baseUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sessionState = {
|
||||
sessionId: nanoid(),
|
||||
requirements,
|
||||
pdfText: '',
|
||||
pdfImages: [],
|
||||
imageStorageIds: [],
|
||||
pdfStorageKey,
|
||||
pdfFileName,
|
||||
pdfProviderId,
|
||||
pdfProviderConfig,
|
||||
sceneOutlines: null,
|
||||
currentStep: 'generating' as const,
|
||||
};
|
||||
sessionStorage.setItem('generationSession', JSON.stringify(sessionState));
|
||||
|
||||
router.push('/generation-preview');
|
||||
} catch (err) {
|
||||
log.error('Error preparing generation:', err);
|
||||
setError(err instanceof Error ? err.message : t('upload.generateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return t('classroom.today');
|
||||
if (diffDays === 1) return t('classroom.yesterday');
|
||||
if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const canGenerate = !!form.requirement.trim();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (canGenerate) handleGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 pt-16 md:p-8 md:pt-16 overflow-x-hidden">
|
||||
{/* ═══ Top-right pill (unchanged) ═══ */}
|
||||
<div ref={toolbarRef} className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Language Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setLanguageOpen(!languageOpen); setThemeOpen(false); }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
|
||||
>
|
||||
{locale === 'zh-CN' ? 'CN' : 'EN'}
|
||||
</button>
|
||||
{languageOpen && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
|
||||
<button
|
||||
onClick={() => { setLocale('zh-CN'); setLanguageOpen(false); }}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||
locale === 'zh-CN' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
简体中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLocale('en-US'); setLanguageOpen(false); }}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||
locale === 'en-US' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
{/* Theme Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setThemeOpen(!themeOpen); setLanguageOpen(false); }}
|
||||
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
|
||||
>
|
||||
{theme === 'light' && <Sun className="w-4 h-4" />}
|
||||
{theme === 'dark' && <Moon className="w-4 h-4" />}
|
||||
{theme === 'system' && <Monitor className="w-4 h-4" />}
|
||||
</button>
|
||||
{themeOpen && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => { setTheme('light'); setThemeOpen(false); }}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'light' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
{t('settings.themeOptions.light')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTheme('dark'); setThemeOpen(false); }}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'dark' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
{t('settings.themeOptions.dark')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTheme('system'); setThemeOpen(false); }}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'system' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t('settings.themeOptions.system')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
{/* Settings Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={cn(
|
||||
"p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group",
|
||||
needsSetup && "animate-setup-glow"
|
||||
)}
|
||||
>
|
||||
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
|
||||
</button>
|
||||
{needsSetup && (
|
||||
<>
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-3 w-3">
|
||||
<span className="animate-setup-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
|
||||
</span>
|
||||
<span className="animate-setup-float absolute top-full mt-2 right-0 whitespace-nowrap text-[11px] font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/50 px-2 py-0.5 rounded-full shadow-sm pointer-events-none">
|
||||
{t('settings.setupNeeded')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={(open) => { setSettingsOpen(open); if (!open) setSettingsSection(undefined); }}
|
||||
initialSection={settingsSection}
|
||||
/>
|
||||
|
||||
{/* ═══ Background Decor ═══ */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDuration: '4s' }} />
|
||||
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDuration: '6s' }} />
|
||||
</div>
|
||||
|
||||
{/* ═══ Hero section: title + input (centered, wider) ═══ */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className={cn(
|
||||
'relative z-20 w-full max-w-[800px] flex flex-col items-center',
|
||||
classrooms.length === 0 ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]'
|
||||
)}
|
||||
>
|
||||
{/* ── Logo ── */}
|
||||
<motion.img
|
||||
src="/logo-horizontal.png"
|
||||
alt="OpenMAIC"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1, type: 'spring', stiffness: 200, damping: 20 }}
|
||||
className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
|
||||
/>
|
||||
|
||||
{/* ── Slogan ── */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-sm text-muted-foreground/60 mb-8"
|
||||
>
|
||||
{t('home.slogan')}
|
||||
</motion.p>
|
||||
|
||||
{/* ── Unified input area ── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]">
|
||||
{/* ── Greeting + Profile + Agents ── */}
|
||||
<div className="relative z-20 flex items-start justify-between">
|
||||
<GreetingBar />
|
||||
<div className="pr-3 pt-3.5 shrink-0">
|
||||
<AgentBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder={t('upload.requirementPlaceholder')}
|
||||
className="w-full resize-none border-0 bg-transparent px-4 pt-1 pb-2 text-[13px] leading-relaxed placeholder:text-muted-foreground/40 focus:outline-none min-h-[140px] max-h-[300px]"
|
||||
value={form.requirement}
|
||||
onChange={(e) => updateForm('requirement', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
{/* Toolbar row */}
|
||||
<div className="px-3 pb-3 flex items-end gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<GenerationToolbar
|
||||
language={form.language}
|
||||
onLanguageChange={(lang) => updateForm('language', lang)}
|
||||
webSearch={form.webSearch}
|
||||
onWebSearchChange={(v) => updateForm('webSearch', v)}
|
||||
onSettingsOpen={(section) => { setSettingsSection(section); setSettingsOpen(true); }}
|
||||
pdfFile={form.pdfFile}
|
||||
onPdfFileChange={(f) => updateForm('pdfFile', f)}
|
||||
onPdfError={setError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Voice input */}
|
||||
<SpeechButton
|
||||
size="md"
|
||||
onTranscription={(text) => {
|
||||
setForm((prev) => {
|
||||
const next = prev.requirement + (prev.requirement ? ' ' : '') + text;
|
||||
updateRequirementCache(next);
|
||||
return { ...prev, requirement: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!canGenerate}
|
||||
className={cn(
|
||||
'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
|
||||
canGenerate
|
||||
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer'
|
||||
: 'bg-muted text-muted-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium">{t('toolbar.enterClassroom')}</span>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Error ── */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 w-full p-3 bg-destructive/10 border border-destructive/20 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* ═══ Recent classrooms — collapsible ═══ */}
|
||||
{classrooms.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center"
|
||||
>
|
||||
{/* Trigger — divider-line with centered text */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !recentOpen;
|
||||
setRecentOpen(next);
|
||||
try { localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next)); } catch { /* ignore */ }
|
||||
}}
|
||||
className="group w-full flex items-center gap-4 py-2 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
|
||||
<span className="shrink-0 flex items-center gap-2 text-[13px] text-muted-foreground/60 group-hover:text-foreground/70 transition-colors select-none">
|
||||
<Clock className="size-3.5" />
|
||||
{t('classroom.recentClassrooms')}
|
||||
<span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span>
|
||||
<motion.div
|
||||
animate={{ rotate: recentOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</motion.div>
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Expandable content */}
|
||||
<AnimatePresence>
|
||||
{recentOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="w-full overflow-hidden"
|
||||
>
|
||||
<div className="pt-8 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-8">
|
||||
{classrooms.map((classroom, i) => (
|
||||
<motion.div
|
||||
key={classroom.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.35, ease: 'easeOut' }}
|
||||
>
|
||||
<ClassroomCard
|
||||
classroom={classroom}
|
||||
slide={thumbnails[classroom.id]}
|
||||
formatDate={formatDate}
|
||||
onDelete={handleDelete}
|
||||
onClick={() => router.push(`/classroom/${classroom.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer — flows with content, at the very end */}
|
||||
<div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40">
|
||||
OpenMAIC Open Source Project
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Greeting Bar — avatar + "Hi, Name", click to edit in-place ────
|
||||
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
function isCustomAvatar(src: string) {
|
||||
return src.startsWith('data:');
|
||||
}
|
||||
|
||||
function GreetingBar() {
|
||||
const { t } = useI18n();
|
||||
const avatar = useUserProfileStore((s) => s.avatar);
|
||||
const nickname = useUserProfileStore((s) => s.nickname);
|
||||
const bio = useUserProfileStore((s) => s.bio);
|
||||
const setAvatar = useUserProfileStore((s) => s.setAvatar);
|
||||
const setNickname = useUserProfileStore((s) => s.setNickname);
|
||||
const setBio = useUserProfileStore((s) => s.setBio);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = nickname || t('profile.defaultNickname');
|
||||
|
||||
// Click-outside to collapse
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setEditingName(false);
|
||||
setAvatarPickerOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const startEditName = () => {
|
||||
setNameDraft(nickname);
|
||||
setEditingName(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const commitName = () => {
|
||||
setNickname(nameDraft.trim());
|
||||
setEditingName(false);
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
toast.error(t('profile.fileTooLarge'));
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error(t('profile.invalidFileType'));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const scale = Math.max(128 / img.width, 128 / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
ctx.drawImage(img, (128 - w) / 2, (128 - h) / 2, w, h);
|
||||
setAvatar(canvas.toDataURL('image/jpeg', 0.85));
|
||||
};
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative pl-4 pr-2 pt-3.5 pb-1 w-auto">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
|
||||
{/* ── Collapsed pill (always in flow) ── */}
|
||||
{!open && (
|
||||
<div
|
||||
className="flex items-center gap-2.5 cursor-pointer transition-all duration-200 group rounded-full px-2.5 py-1.5 border border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60 active:scale-[0.97]"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="shrink-0 relative">
|
||||
<div className="size-8 rounded-full overflow-hidden ring-[1.5px] ring-border/30 group-hover:ring-violet-400/60 dark:group-hover:ring-violet-400/40 transition-all duration-300">
|
||||
<img src={avatar} alt="" className="size-full object-cover" />
|
||||
</div>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full bg-white dark:bg-slate-800 border border-border/40 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<Pencil className="size-[7px] text-muted-foreground/70" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="leading-none select-none flex items-center gap-1">
|
||||
<span>
|
||||
<span className="text-xs text-muted-foreground/60 group-hover:text-muted-foreground transition-colors">{t('home.greeting')}</span>
|
||||
<span className="text-[13px] font-semibold text-foreground/85 group-hover:text-foreground transition-colors">{displayName}</span>
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-colors shrink-0" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{t('profile.editTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Expanded panel (absolute, floating) ── */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.97 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="absolute left-4 top-3.5 z-50 w-64"
|
||||
>
|
||||
<div className="rounded-2xl bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm ring-1 ring-black/[0.04] dark:ring-white/[0.06] shadow-[0_1px_8px_-2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_8px_-2px_rgba(0,0,0,0.3)] px-2.5 py-2">
|
||||
{/* ── Row: avatar + name ── */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 cursor-pointer transition-all duration-200"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setEditingName(false);
|
||||
setAvatarPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="shrink-0 relative cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setAvatarPickerOpen(!avatarPickerOpen); }}
|
||||
>
|
||||
<div className="size-8 rounded-full overflow-hidden ring-[1.5px] ring-violet-300/70 dark:ring-violet-500/40 transition-all duration-300">
|
||||
<img src={avatar} alt="" className="size-full object-cover" />
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full bg-white dark:bg-slate-800 border border-border/60 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-2 text-muted-foreground/70 transition-transform duration-200',
|
||||
avatarPickerOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingName ? (
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitName();
|
||||
if (e.key === 'Escape') { setEditingName(false); }
|
||||
}}
|
||||
onBlur={commitName}
|
||||
maxLength={20}
|
||||
placeholder={t('profile.defaultNickname')}
|
||||
className="flex-1 min-w-0 h-6 bg-transparent border-b border-border/80 text-[13px] font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
/>
|
||||
<button
|
||||
onClick={commitName}
|
||||
className="shrink-0 size-5 rounded flex items-center justify-center text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-900/30"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); startEditName(); }}
|
||||
className="group/name inline-flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] font-semibold text-foreground/85 group-hover/name:text-foreground transition-colors">{displayName}</span>
|
||||
<Pencil className="size-2.5 text-muted-foreground/30 opacity-0 group-hover/name:opacity-100 transition-opacity" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse arrow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="shrink-0 size-6 rounded-full flex items-center justify-center hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<ChevronUp className="size-3.5 text-muted-foreground/50" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* ── Expandable content ── */}
|
||||
<div className="pt-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Avatar picker */}
|
||||
<AnimatePresence>
|
||||
{avatarPickerOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-1 pb-2.5 flex items-center gap-1.5 flex-wrap">
|
||||
{AVATAR_OPTIONS.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
onClick={() => setAvatar(url)}
|
||||
className={cn(
|
||||
'size-7 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150',
|
||||
'hover:scale-110 active:scale-95',
|
||||
avatar === url
|
||||
? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0'
|
||||
: 'hover:ring-1 hover:ring-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
<img src={url} alt="" className="size-full" />
|
||||
</button>
|
||||
))}
|
||||
<label
|
||||
className={cn(
|
||||
'size-7 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isCustomAvatar(avatar)
|
||||
? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0 border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/30'
|
||||
: 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50'
|
||||
)}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
title={t('profile.uploadAvatar')}
|
||||
>
|
||||
<ImagePlus className="size-3" />
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Bio */}
|
||||
<UITextarea
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder={t('profile.bioPlaceholder')}
|
||||
maxLength={200}
|
||||
rows={2}
|
||||
className="resize-none border-border/40 bg-transparent min-h-[72px] !text-[13px] !leading-relaxed placeholder:!text-[11px] placeholder:!leading-relaxed focus-visible:ring-1 focus-visible:ring-border/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Classroom Card — clean, minimal style ──────────────────────
|
||||
function ClassroomCard({
|
||||
classroom,
|
||||
slide,
|
||||
formatDate,
|
||||
onDelete,
|
||||
onClick,
|
||||
}: {
|
||||
classroom: StageListItem;
|
||||
slide?: Slide;
|
||||
formatDate: (ts: number) => string;
|
||||
onDelete: (id: string, e: React.MouseEvent) => void;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbWidth, setThumbWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = thumbRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setThumbWidth(Math.round(entry.contentRect.width));
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="group cursor-pointer" onClick={onClick}>
|
||||
{/* Thumbnail — large radius, no border, subtle bg */}
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className="relative w-full aspect-[16/9] rounded-2xl bg-slate-100 dark:bg-slate-800/80 overflow-hidden transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
>
|
||||
{slide && thumbWidth > 0 ? (
|
||||
<ThumbnailSlide
|
||||
slide={slide}
|
||||
size={thumbWidth}
|
||||
viewportSize={slide.viewportSize ?? 1000}
|
||||
viewportRatio={slide.viewportRatio ?? 0.5625}
|
||||
/>
|
||||
) : !slide ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-12 rounded-2xl bg-gradient-to-br from-violet-100 to-blue-100 dark:from-violet-900/30 dark:to-blue-900/30 flex items-center justify-center">
|
||||
<span className="text-xl opacity-50">📄</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Delete — top-right, only on hover */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2 size-7 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 hover:bg-destructive/80 text-white hover:text-white backdrop-blur-sm rounded-full"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(classroom.id, e); }}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info — outside the thumbnail */}
|
||||
<div className="mt-2.5 px-1 flex items-center gap-2">
|
||||
<span className="shrink-0 inline-flex items-center rounded-full bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 text-[11px] font-medium text-violet-600 dark:text-violet-400">
|
||||
{classroom.sceneCount} {t('classroom.slides')} · {formatDate(classroom.updatedAt)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="font-medium text-[15px] truncate text-foreground/90 min-w-0">{classroom.name}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4} className="!max-w-[min(90vw,32rem)] break-words whitespace-normal">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="break-all">{classroom.name}</span>
|
||||
<button
|
||||
className="shrink-0 p-0.5 rounded hover:bg-foreground/10 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(classroom.name);
|
||||
toast.success(t('classroom.nameCopied'));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <HomePage />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-vega",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Agent Avatar Component
|
||||
* Displays agent avatar and name in chat messages
|
||||
*/
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
interface AgentAvatarProps {
|
||||
avatar: string; // Image URL or emoji
|
||||
color: string; // Theme color (hex)
|
||||
name: string; // Agent display name
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// Check if string is a URL
|
||||
function isUrl(str: string): boolean {
|
||||
return str.startsWith('http') || str.startsWith('/') || str.startsWith('data:');
|
||||
}
|
||||
|
||||
export default function AgentAvatar({
|
||||
avatar,
|
||||
color,
|
||||
name,
|
||||
size = 'md'
|
||||
}: AgentAvatarProps) {
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-6',
|
||||
md: 'size-8',
|
||||
lg: 'size-10'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Avatar
|
||||
className={sizeClasses[size]}
|
||||
style={{ borderColor: color, borderWidth: 2 }}
|
||||
>
|
||||
{isUrl(avatar) ? (
|
||||
<>
|
||||
<AvatarImage src={avatar} alt={name} />
|
||||
<AvatarFallback style={{ backgroundColor: `${color}20`, color }}>
|
||||
{name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</>
|
||||
) : (
|
||||
<AvatarFallback style={{ backgroundColor: `${color}20`, color }}>
|
||||
{avatar || name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
|
||||
import { Sparkles, ChevronDown, ChevronUp, Shuffle } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export function AgentBar() {
|
||||
const { t } = useI18n();
|
||||
const { listAgents } = useAgentRegistry();
|
||||
const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
|
||||
const setSelectedAgentIds = useSettingsStore((s) => s.setSelectedAgentIds);
|
||||
const maxTurns = useSettingsStore((s) => s.maxTurns);
|
||||
const setMaxTurns = useSettingsStore((s) => s.setMaxTurns);
|
||||
const agentMode = useSettingsStore((s) => s.agentMode);
|
||||
const setAgentMode = useSettingsStore((s) => s.setAgentMode);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allAgents = listAgents();
|
||||
// In preset mode, only show default (non-generated) agents
|
||||
const agents = allAgents.filter((a) => !a.isGenerated);
|
||||
const teacherAgent = agents.find((a) => a.role === 'teacher');
|
||||
const selectedAgents = agents.filter((a) => selectedAgentIds.includes(a.id));
|
||||
const nonTeacherSelected = selectedAgents.filter((a) => a.role !== 'teacher');
|
||||
|
||||
// Click-outside to collapse
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleModeChange = (mode: 'preset' | 'auto') => {
|
||||
setAgentMode(mode);
|
||||
if (mode === 'preset') {
|
||||
// Ensure a teacher is always selected in preset mode
|
||||
const hasTeacherSelected = selectedAgentIds.some((id) => {
|
||||
const a = agents.find((agent) => agent.id === id);
|
||||
return a?.role === 'teacher';
|
||||
});
|
||||
if (!hasTeacherSelected && teacherAgent) {
|
||||
setSelectedAgentIds([teacherAgent.id, ...selectedAgentIds]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAgent = (agentId: string) => {
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
if (agent?.role === 'teacher') return; // teacher is always selected
|
||||
if (selectedAgentIds.includes(agentId)) {
|
||||
setSelectedAgentIds(selectedAgentIds.filter((id) => id !== agentId));
|
||||
} else {
|
||||
setSelectedAgentIds([...selectedAgentIds, agentId]);
|
||||
}
|
||||
};
|
||||
|
||||
const getAgentName = (agent: { id: string; name: string }) => {
|
||||
const key = `settings.agentNames.${agent.id}`;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : agent.name;
|
||||
};
|
||||
|
||||
const getAgentRole = (agent: { role: string }) => {
|
||||
const key = `settings.agentRoles.${agent.role}`;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : agent.role;
|
||||
};
|
||||
|
||||
/* ── Shared avatar row — always visible on the right side ── */
|
||||
const avatarRow = (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Teacher avatar — always shown */}
|
||||
{teacherAgent && (
|
||||
<div className="size-8 rounded-full overflow-hidden ring-2 ring-blue-400/40 dark:ring-blue-500/30 shrink-0">
|
||||
<img src={teacherAgent.avatar} alt={getAgentName(teacherAgent)} className="size-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentMode === 'auto' ? (
|
||||
<>
|
||||
{/* In auto mode: show assistant avatar + shuffle indicator */}
|
||||
<div className="flex -space-x-2">
|
||||
{agents.find((a) => a.role === 'assistant') && (
|
||||
<div className="size-6 rounded-full overflow-hidden ring-[1.5px] ring-background">
|
||||
<img src={agents.find((a) => a.role === 'assistant')!.avatar} alt="" className="size-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Shuffle className="size-4 text-violet-400 dark:text-violet-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* In preset mode: show selected non-teacher agents */}
|
||||
{nonTeacherSelected.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{nonTeacherSelected.slice(0, 4).map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="size-6 rounded-full overflow-hidden ring-[1.5px] ring-background"
|
||||
>
|
||||
<img src={agent.avatar} alt={getAgentName(agent)} className="size-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{nonTeacherSelected.length > 4 && (
|
||||
<div className="size-6 rounded-full bg-muted ring-[1.5px] ring-background flex items-center justify-center">
|
||||
<span className="text-[9px] font-bold text-muted-foreground">+{nonTeacherSelected.length - 4}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-80">
|
||||
{/* ── Header row — always in document flow ── */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group flex items-center gap-2 cursor-pointer rounded-full px-2.5 py-2 transition-all w-full',
|
||||
'border border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{/* Left side — text changes based on open/close */}
|
||||
<span className="text-xs text-muted-foreground/60 group-hover:text-muted-foreground transition-colors hidden sm:block font-medium flex-1 text-left">
|
||||
{open ? t('agentBar.expandedTitle') : t('agentBar.readyToLearn')}
|
||||
</span>
|
||||
|
||||
{/* Right side — avatars always visible */}
|
||||
{avatarRow}
|
||||
|
||||
{/* Chevron */}
|
||||
{open ? (
|
||||
<ChevronUp className="size-3 text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
|
||||
) : (
|
||||
<ChevronDown className="size-3 text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{!open && (
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{t('agentBar.configTooltip')}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* ── Expanded panel (absolute, floating below the header) ── */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.97 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="absolute right-0 top-full mt-1 z-50 w-80"
|
||||
>
|
||||
<div className="rounded-2xl bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm ring-1 ring-black/[0.04] dark:ring-white/[0.06] shadow-[0_1px_8px_-2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_8px_-2px_rgba(0,0,0,0.3)] px-2.5 py-2">
|
||||
{/* Mode tabs — full width, 50/50 */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-0.5 mb-2.5">
|
||||
<button
|
||||
onClick={() => handleModeChange('preset')}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-xs font-medium rounded-md transition-all text-center',
|
||||
agentMode === 'preset'
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('settings.agentModePreset')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('auto')}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-xs font-medium rounded-md transition-all text-center flex items-center justify-center gap-1',
|
||||
agentMode === 'auto'
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('settings.agentModeAuto')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{agentMode === 'preset' ? (
|
||||
/* Agent list — teacher is always selected, no need to show */
|
||||
<div className="max-h-72 overflow-y-auto -mx-1">
|
||||
{agents.filter((a) => a.role !== 'teacher').map((agent) => {
|
||||
const isSelected = selectedAgentIds.includes(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
onClick={() => toggleAgent(agent.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer rounded-lg',
|
||||
isSelected ? 'bg-primary/5' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
className="size-8 rounded-full overflow-hidden shrink-0 ring-1 ring-border/40"
|
||||
style={{ boxShadow: isSelected ? `0 0 0 2px ${agent.color}30` : undefined }}
|
||||
>
|
||||
<img src={agent.avatar} alt={getAgentName(agent)} className="size-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium flex items-center gap-1.5">
|
||||
{getAgentName(agent)}
|
||||
<span className="text-[10px] text-muted-foreground/50 font-normal">{getAgentRole(agent)}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const descKey = `settings.agentDescriptions.${agent.id}`;
|
||||
const desc = t(descKey);
|
||||
return desc !== descKey ? (
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5 leading-relaxed">{desc}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Auto-generate mode */
|
||||
<div className="flex flex-col items-center pt-6 pb-2 gap-8">
|
||||
{/* Shuffle icon with ambient animation */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Ping ripple */}
|
||||
<div className="absolute size-12 rounded-full bg-violet-400/10 dark:bg-violet-400/15 animate-ping [animation-duration:3s]" />
|
||||
{/* Soft glow ring */}
|
||||
<div className="absolute size-14 rounded-full bg-violet-400/5 dark:bg-violet-400/10 animate-pulse [animation-duration:2.5s]" />
|
||||
{/* Icon */}
|
||||
<Shuffle className="relative size-7 text-violet-400 dark:text-violet-500" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">{t('settings.agentModeAutoDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Max turns — always visible */}
|
||||
<div className="pt-2.5 mt-2.5 border-t flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('settings.maxTurns')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxTurns}
|
||||
onChange={(e) => setMaxTurns(e.target.value)}
|
||||
className="w-16 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Agent Configuration Panel
|
||||
* UI for viewing and managing AI agents in the registry
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { PlusIcon, Trash2Icon, EditIcon } from 'lucide-react';
|
||||
|
||||
export function AgentConfigPanel() {
|
||||
const { listAgents, deleteAgent } = useAgentRegistry();
|
||||
const agents = listAgents();
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = (agentId: string) => {
|
||||
if (confirm('确定要删除这个智能体吗?')) {
|
||||
deleteAgent(agentId);
|
||||
if (selectedAgent === agentId) {
|
||||
setSelectedAgent(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">智能体配置</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理课堂讨论的AI智能体
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedAgent === agent.id ? 'border-primary' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedAgent(agent.id)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
className="size-10"
|
||||
style={{
|
||||
borderColor: agent.color,
|
||||
borderWidth: 2,
|
||||
}}
|
||||
>
|
||||
<AvatarImage src={agent.avatar} alt={agent.name} />
|
||||
<AvatarFallback style={{ backgroundColor: `${agent.color}20`, color: agent.color }}>
|
||||
{agent.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-base">{agent.name}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{agent.role}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
优先级 {agent.priority}
|
||||
</Badge>
|
||||
{agent.isDefault && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
能力描述
|
||||
</p>
|
||||
<p className="text-sm line-clamp-2">{agent.persona}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
可用动作 ({agent.allowedActions.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.allowedActions.slice(0, 3).map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-xs">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
{agent.allowedActions.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{agent.allowedActions.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!agent.isDefault && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<EditIcon className="w-3 h-3 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(agent.id);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{agents.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-center p-8">
|
||||
<div className="max-w-sm">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
还没有配置智能体
|
||||
</p>
|
||||
<Button>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
创建第一个智能体
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
|
||||
interface AgentRevealModalProps {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
persona: string;
|
||||
avatar: string;
|
||||
color: string;
|
||||
}>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called once after all cards are revealed — signals generation can continue */
|
||||
onAllRevealed?: () => void;
|
||||
}
|
||||
|
||||
function isUrl(str: string): boolean {
|
||||
return str.startsWith('http') || str.startsWith('/') || str.startsWith('data:');
|
||||
}
|
||||
|
||||
/** Lighten a hex color by mixing with white */
|
||||
function lighten(hex: string, amount: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const lr = Math.round(r + (255 - r) * amount);
|
||||
const lg = Math.round(g + (255 - g) * amount);
|
||||
const lb = Math.round(b + (255 - b) * amount);
|
||||
return `rgb(${lr},${lg},${lb})`;
|
||||
}
|
||||
|
||||
const ROLE_ICONS: Record<string, string> = {
|
||||
teacher: '👨🏫',
|
||||
assistant: '🤝',
|
||||
student: '🎓',
|
||||
};
|
||||
|
||||
export function AgentRevealModal({ agents, open, onClose, onAllRevealed }: AgentRevealModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [revealedCount, setRevealedCount] = useState(0);
|
||||
const [flipsComplete, setFlipsComplete] = useState(false);
|
||||
const allRevealedFiredRef = useRef(false);
|
||||
const onAllRevealedRef = useRef(onAllRevealed);
|
||||
onAllRevealedRef.current = onAllRevealed;
|
||||
|
||||
const allRevealed = revealedCount >= agents.length && agents.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRevealedCount(0);
|
||||
setFlipsComplete(false);
|
||||
allRevealedFiredRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const startTimeout = setTimeout(() => {
|
||||
i = 1;
|
||||
setRevealedCount(1);
|
||||
|
||||
if (agents.length <= 1) {
|
||||
setTimeout(() => {
|
||||
if (!allRevealedFiredRef.current) {
|
||||
allRevealedFiredRef.current = true;
|
||||
onAllRevealedRef.current?.();
|
||||
}
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
i++;
|
||||
setRevealedCount(i);
|
||||
if (i >= agents.length) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
if (!allRevealedFiredRef.current) {
|
||||
allRevealedFiredRef.current = true;
|
||||
onAllRevealedRef.current?.();
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(startTimeout);
|
||||
}, [open, agents.length]);
|
||||
|
||||
// Switch from preserve-3d to flat after all flip animations complete to enable scrolling
|
||||
useEffect(() => {
|
||||
if (!allRevealed) return;
|
||||
const timer = setTimeout(() => setFlipsComplete(true), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [allRevealed]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-zinc-100/90 backdrop-blur-sm dark:bg-black/70 dark:backdrop-blur-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Close button */}
|
||||
{allRevealed && (
|
||||
<motion.button
|
||||
className="absolute right-4 top-4 flex size-9 items-center justify-center rounded-full bg-black/5 text-zinc-500 backdrop-blur-sm transition-colors hover:bg-black/10 hover:text-zinc-700 dark:bg-white/10 dark:text-white/70 dark:hover:bg-white/20 dark:hover:text-white"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
className="mb-8 text-2xl font-bold text-zinc-800 drop-shadow-sm dark:text-white dark:drop-shadow-lg md:text-3xl"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
>
|
||||
<Sparkles className="mr-2 inline-block size-6 text-amber-500 dark:text-yellow-400" />
|
||||
{t('generation.agentRevealTitle')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex flex-wrap items-stretch justify-center gap-4 px-4 md:gap-5">
|
||||
{agents.map((agent, index) => {
|
||||
const isRevealed = index < revealedCount;
|
||||
const roleIcon = ROLE_ICONS[agent.role] ?? '🎓';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
className="group relative"
|
||||
style={{ width: 196, height: 290, perspective: 900 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.08, duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative size-full"
|
||||
style={{ transformStyle: flipsComplete ? 'flat' : 'preserve-3d' }}
|
||||
animate={{ rotateY: isRevealed ? 0 : 180 }}
|
||||
transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{/* ====== FRONT FACE ====== */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-clip rounded-2xl"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{/* Outer colored border */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl p-[2px]"
|
||||
style={{
|
||||
background: `linear-gradient(160deg, ${agent.color}, ${lighten(agent.color, 0.35)}, ${agent.color})`,
|
||||
}}
|
||||
>
|
||||
{/* Inner card body */}
|
||||
<div className="relative flex size-full flex-col overflow-clip rounded-[14px] bg-white dark:bg-zinc-900">
|
||||
{/* Top gradient band with texture */}
|
||||
<div
|
||||
className="relative shrink-0 overflow-hidden"
|
||||
style={{ height: 56 }}
|
||||
>
|
||||
{/* Color gradient fill */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${agent.color}30 0%, ${agent.color}10 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Subtle noise texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
{/* Decorative corner accent lines */}
|
||||
<svg className="absolute right-0 top-0 size-16 text-white/[0.06]" viewBox="0 0 64 64">
|
||||
<line x1="64" y1="0" x2="0" y2="64" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="64" y1="16" x2="16" y2="64" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="64" y1="32" x2="32" y2="64" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Avatar — overlapping the band */}
|
||||
<div className="relative z-10 -mt-7 flex justify-center">
|
||||
<div
|
||||
className="flex size-[50px] items-center justify-center rounded-full border-[2.5px] shadow-lg shadow-black/40"
|
||||
style={{
|
||||
borderColor: agent.color,
|
||||
backgroundColor: '#f8f8fc',
|
||||
}}
|
||||
>
|
||||
{isUrl(agent.avatar) ? (
|
||||
|
||||
<img
|
||||
src={agent.avatar}
|
||||
alt={agent.name}
|
||||
className="size-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl">{agent.avatar || agent.name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name + role row */}
|
||||
<div className="mt-1.5 flex flex-col items-center gap-0.5 px-3">
|
||||
<h3
|
||||
className="max-w-full truncate text-center text-[13px] font-bold tracking-wide"
|
||||
style={{ color: agent.color }}
|
||||
>
|
||||
{agent.name}
|
||||
</h3>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-px text-[10px] font-medium"
|
||||
style={{
|
||||
color: agent.color,
|
||||
backgroundColor: `${agent.color}12`,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px]">{roleIcon}</span>
|
||||
{t(`settings.agentRoles.${agent.role}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Thin ornamental divider */}
|
||||
<div className="mx-5 mt-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1" style={{ background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)` }} />
|
||||
<div className="size-1 rounded-full" style={{ backgroundColor: `${agent.color}60` }} />
|
||||
<div className="h-px flex-1" style={{ background: `linear-gradient(to right, transparent, ${agent.color}40, transparent)` }} />
|
||||
</div>
|
||||
|
||||
{/* Persona text — fills remaining space */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pt-1.5 pb-3">
|
||||
<p className="text-left text-[10.5px] leading-[1.65] text-zinc-600 dark:text-zinc-400">
|
||||
{agent.persona}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom edge glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-8"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${agent.color}08, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ====== BACK FACE ====== */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
{/* Gradient border matching front style */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl p-[2px]"
|
||||
style={{
|
||||
background: 'linear-gradient(160deg, #6366f1, #a855f7, #6366f1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex size-full flex-col items-center justify-center rounded-[14px]"
|
||||
style={{
|
||||
background: 'linear-gradient(145deg, #1e1b4b 0%, #312e81 40%, #1e1b4b 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Decorative inner border */}
|
||||
<div className="absolute inset-3 rounded-xl border border-white/[0.08]" />
|
||||
{/* Diamond pattern corners */}
|
||||
<svg className="absolute left-3 top-3 size-5 text-white/[0.07]" viewBox="0 0 20 20">
|
||||
<path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
|
||||
</svg>
|
||||
<svg className="absolute right-3 top-3 size-5 text-white/[0.07]" viewBox="0 0 20 20">
|
||||
<path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
|
||||
</svg>
|
||||
<svg className="absolute bottom-3 left-3 size-5 text-white/[0.07]" viewBox="0 0 20 20">
|
||||
<path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
|
||||
</svg>
|
||||
<svg className="absolute bottom-3 right-3 size-5 text-white/[0.07]" viewBox="0 0 20 20">
|
||||
<path d="M10 0 L20 10 L10 20 L0 10 Z" fill="currentColor" />
|
||||
</svg>
|
||||
{/* Center icon */}
|
||||
<Sparkles className="size-9 text-purple-300/70" />
|
||||
<span className="mt-1.5 text-xl font-bold text-purple-200/60">?</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress dots + continue */}
|
||||
<motion.div
|
||||
className="mt-6 flex flex-col items-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{agents.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'size-2 rounded-full transition-colors duration-300',
|
||||
index < revealedCount
|
||||
? 'bg-zinc-700 dark:bg-white'
|
||||
: 'bg-zinc-300 dark:bg-white/30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allRevealed && (
|
||||
<motion.button
|
||||
className="rounded-full bg-zinc-800 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-white/15 dark:hover:bg-white/25"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('generation.continue')}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type LucideIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn("font-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div
|
||||
className={cn("not-prose max-w-prose space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: "text-muted-foreground",
|
||||
active: "text-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
statusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookmarkIcon, type LucideProps } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-0.5 text-muted-foreground overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div
|
||||
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
type ToolUIPartApproval =
|
||||
| {
|
||||
id: string;
|
||||
approved?: never;
|
||||
reason?: never;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: false;
|
||||
reason?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type ConfirmationContextValue = {
|
||||
approval: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Confirmation components must be used within Confirmation");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ approval, state }}>
|
||||
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn("inline", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConfirmationRequestProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationAcceptedProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationRejectedProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
|
||||
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-1 stroke-ring"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left:
|
||||
offsetX = 0;
|
||||
break;
|
||||
case Position.Right:
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
case Position.Top:
|
||||
offsetY = 0;
|
||||
break;
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type InlineCitationProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn("group inline items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn("transition-colors group-hover:bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn("ml-1 rounded-full", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{" "}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
"unknown"
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn("w-full space-y-2 p-4 pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const count = api?.scrollSnapList().length ?? 0;
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Initial sync from external embla carousel API
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
const onSelect = () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
};
|
||||
api.on("select", onSelect);
|
||||
return () => {
|
||||
api.off("select", onSelect);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => Array.isArray(children) ? children : [children],
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className: _className,
|
||||
from: _from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className: _className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: "Open in GitHub",
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: "Open in Scira",
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: "Open in ChatGPT",
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: "Open in Claude",
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: "Open in T3 Chat",
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in v0",
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
|
||||
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Open in Cursor",
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"m-4 overflow-hidden rounded-md border bg-card p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
};
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
};
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Tracking streaming duration requires effect
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, getThinkingMessage = defaultGetThinkingMessage, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type MotionProps, motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
type MotionComponentType = React.FC<MotionProps & React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>;
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
/* eslint-disable react-hooks/refs -- Ref-based cache for motion.create component identity */
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const motionRef = useRef<MotionComponentType | null>(null);
|
||||
const prevComponentRef = useRef(Component);
|
||||
if (!motionRef.current || prevComponentRef.current !== Component) {
|
||||
motionRef.current = motion.create(Component as keyof JSX.IntrinsicElements) as unknown as MotionComponentType;
|
||||
prevComponentRef.current = Component;
|
||||
}
|
||||
const MotionComponent = motionRef.current;
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
/* eslint-enable react-hooks/refs */
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 text-primary text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-3 flex w-fit flex-col gap-2",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.(suggestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col rounded-lg border bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url);
|
||||
}, [url]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: Array<{
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"px-4 pb-4",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground"
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SceneRenderer } from '@/components/stage/scene-renderer';
|
||||
import { SceneProvider } from '@/lib/store/scene-context';
|
||||
import { Whiteboard } from '@/components/whiteboard';
|
||||
import { CanvasToolbar } from '@/components/canvas-toolbar';
|
||||
import type { CanvasToolbarProps } from '@/components/canvas-toolbar';
|
||||
import type { Scene, StageMode } from '@/lib/types/stage';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
|
||||
interface CanvasAreaProps extends CanvasToolbarProps {
|
||||
readonly currentScene: Scene | null;
|
||||
readonly mode: StageMode;
|
||||
readonly hideToolbar?: boolean;
|
||||
readonly isPendingScene?: boolean;
|
||||
readonly isGenerationFailed?: boolean;
|
||||
readonly onRetryGeneration?: () => void;
|
||||
}
|
||||
|
||||
export function CanvasArea({
|
||||
currentScene,
|
||||
currentSceneIndex,
|
||||
scenesCount,
|
||||
mode,
|
||||
engineState,
|
||||
isLiveSession,
|
||||
whiteboardOpen,
|
||||
sidebarCollapsed,
|
||||
chatCollapsed,
|
||||
onToggleSidebar,
|
||||
onToggleChat,
|
||||
onPrevSlide,
|
||||
onNextSlide,
|
||||
onPlayPause,
|
||||
onWhiteboardClose,
|
||||
showStopDiscussion,
|
||||
onStopDiscussion,
|
||||
hideToolbar,
|
||||
isPendingScene,
|
||||
isGenerationFailed,
|
||||
onRetryGeneration,
|
||||
}: CanvasAreaProps) {
|
||||
const { t } = useI18n();
|
||||
const showControls = mode === 'playback' && !whiteboardOpen;
|
||||
const showPlayHint = showControls && engineState !== 'playing' && currentScene?.type === 'slide' && !isLiveSession && !isPendingScene;
|
||||
|
||||
const handleSlideClick = useCallback((e: React.MouseEvent) => {
|
||||
if (!showControls || isLiveSession || currentScene?.type !== 'slide') return;
|
||||
// Don't trigger page play/pause when clicking inside a video element's visual area.
|
||||
// Video elements may be visually covered by other slide elements (e.g. text),
|
||||
// so we check click coordinates against all video element bounding rects.
|
||||
const container = e.currentTarget as HTMLElement;
|
||||
const videoEls = container.querySelectorAll('[data-video-element]');
|
||||
for (const el of videoEls) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onPlayPause();
|
||||
}, [showControls, isLiveSession, onPlayPause, currentScene?.type]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-gray-50 dark:bg-gray-900 group/canvas">
|
||||
{/* Slide area — takes remaining space */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 min-h-0 relative overflow-hidden flex items-center justify-center p-2 transition-colors duration-500',
|
||||
currentScene?.type === 'interactive'
|
||||
? 'bg-blue-50/30 dark:bg-blue-900/10'
|
||||
: 'bg-gray-50/30 dark:bg-gray-900/30',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'aspect-[16/9] h-full max-h-full max-w-full bg-white dark:bg-gray-800 shadow-2xl rounded-lg overflow-hidden relative transition-all duration-700',
|
||||
showControls && !isLiveSession && currentScene?.type === 'slide' && 'cursor-pointer',
|
||||
currentScene?.type === 'interactive'
|
||||
? 'shadow-blue-200/50 dark:shadow-blue-900/50 ring-1 ring-blue-900/5 dark:ring-blue-500/10'
|
||||
: 'shadow-gray-200/50 dark:shadow-gray-800/50 ring-1 ring-gray-950/5 dark:ring-white/5',
|
||||
)}
|
||||
onClick={handleSlideClick}
|
||||
>
|
||||
{/* Whiteboard Layer */}
|
||||
<div className="absolute inset-0 z-[110] pointer-events-none">
|
||||
<SceneProvider>
|
||||
<Whiteboard isOpen={whiteboardOpen} onClose={onWhiteboardClose} />
|
||||
</SceneProvider>
|
||||
</div>
|
||||
|
||||
{/* Scene Content */}
|
||||
{currentScene && !whiteboardOpen && (
|
||||
<div className="absolute inset-0">
|
||||
<SceneProvider>
|
||||
<SceneRenderer scene={currentScene} mode={mode} />
|
||||
</SceneProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Scene Loading Overlay */}
|
||||
<AnimatePresence>
|
||||
{isPendingScene && !currentScene && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="absolute inset-0 z-[105] flex flex-col items-center justify-center bg-white dark:bg-gray-800"
|
||||
>
|
||||
{isGenerationFailed ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400 dark:text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-red-500 dark:text-red-400 font-medium">{t('stage.generationFailed')}</span>
|
||||
{onRetryGeneration && (
|
||||
<button
|
||||
onClick={onRetryGeneration}
|
||||
className="mt-1 px-4 py-1.5 text-xs font-medium rounded-full bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors active:scale-95"
|
||||
>
|
||||
{t('generation.retryScene')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Spinner */}
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-gray-100 dark:border-gray-700" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-purple-500 dark:border-t-purple-400 animate-spin" />
|
||||
</div>
|
||||
{/* Text */}
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
className="text-sm text-gray-400 dark:text-gray-500 font-medium"
|
||||
>
|
||||
{t('stage.generatingNextPage')}
|
||||
</motion.span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Scene Number Badge */}
|
||||
{currentScene && (
|
||||
<div className="absolute top-4 right-4 text-gray-200 dark:text-gray-700 font-black text-4xl opacity-50 pointer-events-none select-none mix-blend-multiply dark:mix-blend-screen">
|
||||
{(currentSceneIndex + 1).toString().padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play hint — breathing button when idle or paused (slides only) */}
|
||||
<AnimatePresence>
|
||||
{showPlayHint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<motion.div
|
||||
className="opacity-50 group-hover/canvas:opacity-100 transition-opacity duration-300 pointer-events-auto cursor-pointer"
|
||||
exit={{ pointerEvents: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); onPlayPause(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85 }}
|
||||
animate={{ scale: [1, 1.06] }}
|
||||
exit={{ scale: 1.15, opacity: 0 }}
|
||||
transition={{ default: { duration: 0.3, ease: [0.4, 0, 0.2, 1] }, scale: { repeat: Infinity, repeatType: 'mirror', duration: 1, ease: 'easeInOut' } }}
|
||||
className="w-20 h-20 rounded-full bg-white/95 dark:bg-gray-800/95 flex items-center justify-center shadow-[0_4px_30px_rgba(147,51,234,0.15),inset_0_0_0_1px_rgba(233,213,255,0.5)] dark:shadow-[0_4px_30px_rgba(147,51,234,0.3),inset_0_0_0_1px_rgba(126,34,206,0.3)]"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
<Play className="w-7 h-7 text-purple-600 dark:text-purple-400 fill-purple-600/90 dark:fill-purple-400/90 ml-0.5" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Canvas Toolbar — in document flow, only when not merged into roundtable ── */}
|
||||
{!hideToolbar && (
|
||||
<CanvasToolbar
|
||||
className={cn(
|
||||
'shrink-0 h-9 px-2',
|
||||
'bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl',
|
||||
'border-t border-gray-200/40 dark:border-gray-700/40',
|
||||
)}
|
||||
currentSceneIndex={currentSceneIndex}
|
||||
scenesCount={scenesCount}
|
||||
engineState={engineState}
|
||||
isLiveSession={isLiveSession}
|
||||
whiteboardOpen={whiteboardOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
chatCollapsed={chatCollapsed}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onToggleChat={onToggleChat}
|
||||
onPrevSlide={onPrevSlide}
|
||||
onNextSlide={onNextSlide}
|
||||
onPlayPause={onPlayPause}
|
||||
onWhiteboardClose={onWhiteboardClose}
|
||||
showStopDiscussion={showStopDiscussion}
|
||||
onStopDiscussion={onStopDiscussion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Play, Pause, PencilLine, LayoutList, MessageSquare, Volume1, Volume2, VolumeX, Repeat } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStageStore } from '@/lib/store';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export interface CanvasToolbarProps {
|
||||
readonly currentSceneIndex: number;
|
||||
readonly scenesCount: number;
|
||||
readonly engineState: 'idle' | 'playing' | 'paused';
|
||||
readonly isLiveSession?: boolean;
|
||||
readonly whiteboardOpen: boolean;
|
||||
readonly sidebarCollapsed?: boolean;
|
||||
readonly chatCollapsed?: boolean;
|
||||
readonly onToggleSidebar?: () => void;
|
||||
readonly onToggleChat?: () => void;
|
||||
readonly onPrevSlide: () => void;
|
||||
readonly onNextSlide: () => void;
|
||||
readonly onPlayPause: () => void;
|
||||
readonly onWhiteboardClose: () => void;
|
||||
readonly showStopDiscussion?: boolean;
|
||||
readonly onStopDiscussion?: () => void;
|
||||
readonly className?: string;
|
||||
// Audio/playback controls
|
||||
readonly ttsEnabled?: boolean;
|
||||
readonly ttsMuted?: boolean;
|
||||
readonly ttsVolume?: number;
|
||||
readonly onToggleMute?: () => void;
|
||||
readonly onVolumeChange?: (volume: number) => void;
|
||||
readonly autoPlayLecture?: boolean;
|
||||
readonly onToggleAutoPlay?: () => void;
|
||||
readonly playbackSpeed?: number;
|
||||
readonly onCycleSpeed?: () => void;
|
||||
}
|
||||
|
||||
/* Compact control button */
|
||||
const ctrlBtn = cn(
|
||||
'relative w-7 h-7 rounded-md flex items-center justify-center',
|
||||
'transition-all duration-150 outline-none cursor-pointer',
|
||||
'hover:bg-gray-500/[0.08] dark:hover:bg-gray-400/[0.08] active:scale-90',
|
||||
);
|
||||
|
||||
/* Subtle separator */
|
||||
function CtrlDivider() {
|
||||
return <div className="w-px h-3 bg-gray-200/80 dark:bg-gray-700/60 mx-0.5 shrink-0" />;
|
||||
}
|
||||
|
||||
/* Volume icon based on level */
|
||||
function VolumeIcon({ muted, volume, disabled }: { muted: boolean; volume: number; disabled: boolean }) {
|
||||
const cls = "w-3.5 h-3.5";
|
||||
if (disabled || muted || volume === 0) return <VolumeX className={cls} />;
|
||||
if (volume < 0.5) return <Volume1 className={cls} />;
|
||||
return <Volume2 className={cls} />;
|
||||
}
|
||||
|
||||
export function CanvasToolbar({
|
||||
currentSceneIndex,
|
||||
scenesCount,
|
||||
engineState,
|
||||
isLiveSession,
|
||||
whiteboardOpen,
|
||||
sidebarCollapsed,
|
||||
chatCollapsed,
|
||||
onToggleSidebar,
|
||||
onToggleChat,
|
||||
onPrevSlide,
|
||||
onNextSlide,
|
||||
onPlayPause,
|
||||
onWhiteboardClose,
|
||||
showStopDiscussion,
|
||||
onStopDiscussion,
|
||||
className,
|
||||
ttsEnabled,
|
||||
ttsMuted,
|
||||
ttsVolume = 1,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
autoPlayLecture,
|
||||
onToggleAutoPlay,
|
||||
playbackSpeed = 1,
|
||||
onCycleSpeed,
|
||||
}: CanvasToolbarProps) {
|
||||
const { t } = useI18n();
|
||||
const canGoPrev = currentSceneIndex > 0;
|
||||
const canGoNext = currentSceneIndex < scenesCount - 1;
|
||||
const showPlayPause = !isLiveSession;
|
||||
|
||||
const whiteboardElementCount = useStageStore(
|
||||
(s) => s.stage?.whiteboard?.[0]?.elements?.length || 0,
|
||||
);
|
||||
|
||||
// Volume slider hover state
|
||||
const [volumeHover, setVolumeHover] = useState(false);
|
||||
const volumeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const volumeContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleVolumeEnter = useCallback(() => {
|
||||
clearTimeout(volumeTimerRef.current);
|
||||
setVolumeHover(true);
|
||||
}, []);
|
||||
|
||||
const handleVolumeLeave = useCallback(() => {
|
||||
volumeTimerRef.current = setTimeout(() => setVolumeHover(false), 300);
|
||||
}, []);
|
||||
|
||||
// Cleanup volume hover timer on unmount
|
||||
useEffect(() => () => clearTimeout(volumeTimerRef.current), []);
|
||||
|
||||
// Effective volume for display
|
||||
const effectiveVolume = ttsMuted ? 0 : ttsVolume;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{/* ── Left: sidebar toggle + page indicator ── */}
|
||||
<div className="flex items-center gap-1 shrink-0 pl-1">
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(ctrlBtn, 'w-6 h-6',
|
||||
sidebarCollapsed
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-600 dark:text-gray-300',
|
||||
)}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<LayoutList className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 tabular-nums select-none font-medium">
|
||||
{currentSceneIndex + 1}
|
||||
<span className="opacity-35 mx-px">/</span>
|
||||
{scenesCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Center: unified playback controls ── */}
|
||||
<div className="flex-1 flex items-center justify-center min-w-0">
|
||||
<div className="inline-flex items-center gap-0.5 bg-gray-100/60 dark:bg-gray-800/60 rounded-lg px-1 h-7">
|
||||
{/* Volume with vertical popover slider */}
|
||||
{onToggleMute && (
|
||||
<div
|
||||
ref={volumeContainerRef}
|
||||
className="relative flex items-center"
|
||||
onMouseEnter={handleVolumeEnter}
|
||||
onMouseLeave={handleVolumeLeave}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleMute}
|
||||
disabled={!ttsEnabled}
|
||||
className={cn(ctrlBtn, 'w-6 h-6',
|
||||
!ttsEnabled
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: ttsMuted
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
aria-label={ttsMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<VolumeIcon muted={!!ttsMuted} volume={ttsVolume} disabled={!ttsEnabled} />
|
||||
</button>
|
||||
|
||||
{/* Vertical volume slider (pops up above) */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 flex flex-col items-center',
|
||||
'transition-all duration-200 ease-out pointer-events-none opacity-0',
|
||||
volumeHover && ttsEnabled && 'pointer-events-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg px-2 py-2.5 flex flex-col items-center gap-1.5">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums font-medium select-none">
|
||||
{Math.round(effectiveVolume * 100)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={effectiveVolume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
onVolumeChange?.(v);
|
||||
if (v > 0 && ttsMuted) onToggleMute?.();
|
||||
}}
|
||||
className={cn(
|
||||
'appearance-none cursor-pointer',
|
||||
'h-16 w-1 rounded-full',
|
||||
'bg-gray-200 dark:bg-gray-600',
|
||||
'[writing-mode:vertical-lr] [direction:rtl]',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3',
|
||||
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-violet-500 [&::-webkit-slider-thumb]:dark:bg-violet-400',
|
||||
'[&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
|
||||
'[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3',
|
||||
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-violet-500 [&::-moz-range-thumb]:border-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Arrow pointing down */}
|
||||
<div className="w-2 h-2 bg-white dark:bg-gray-800 border-b border-r border-gray-200 dark:border-gray-700 rotate-45 -mt-[5px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speed */}
|
||||
{onCycleSpeed && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onCycleSpeed}
|
||||
className={cn(
|
||||
'w-8 h-5 rounded flex items-center justify-center',
|
||||
'transition-all duration-150 outline-none cursor-pointer',
|
||||
'text-[11px] font-semibold tabular-nums leading-none',
|
||||
'active:scale-90',
|
||||
playbackSpeed !== 1
|
||||
? 'text-violet-600 dark:text-violet-400 bg-violet-500/10 dark:bg-violet-400/10'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200',
|
||||
)}
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{playbackSpeed === 1.5 ? '1.5x' : `${playbackSpeed}x`}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{t('roundtable.speed')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<CtrlDivider />
|
||||
|
||||
{/* Prev scene */}
|
||||
{scenesCount > 1 && (
|
||||
<button
|
||||
onClick={onPrevSlide}
|
||||
disabled={!canGoPrev}
|
||||
className={cn(ctrlBtn, 'w-6 h-6 text-gray-500 dark:text-gray-400 disabled:opacity-20 disabled:pointer-events-none')}
|
||||
aria-label="Previous scene"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Play / Pause / Stop Discussion */}
|
||||
{showStopDiscussion && onStopDiscussion ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onStopDiscussion(); }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 h-6 px-2.5 rounded-md',
|
||||
'bg-red-500/10 dark:bg-red-400/10 text-red-600 dark:text-red-400',
|
||||
'text-[11px] font-semibold whitespace-nowrap',
|
||||
'hover:bg-red-500/20 dark:hover:bg-red-400/20 active:scale-95 transition-all cursor-pointer',
|
||||
)}
|
||||
title={t('roundtable.stopDiscussion')}
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-red-500" />
|
||||
</span>
|
||||
{t('roundtable.stopDiscussion')}
|
||||
</button>
|
||||
) : showPlayPause ? (
|
||||
<button
|
||||
onClick={onPlayPause}
|
||||
className={cn(ctrlBtn, 'w-7 h-6',
|
||||
engineState === 'playing'
|
||||
? 'text-violet-600 dark:text-violet-400'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
aria-label={engineState === 'playing' ? 'Pause' : 'Play'}
|
||||
>
|
||||
{engineState === 'playing'
|
||||
? <Pause className="w-3.5 h-3.5" />
|
||||
: <Play className="w-3.5 h-3.5 ml-px" />}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Next scene */}
|
||||
{scenesCount > 1 && (
|
||||
<button
|
||||
onClick={onNextSlide}
|
||||
disabled={!canGoNext}
|
||||
className={cn(ctrlBtn, 'w-6 h-6 text-gray-500 dark:text-gray-400 disabled:opacity-20 disabled:pointer-events-none')}
|
||||
aria-label="Next scene"
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CtrlDivider />
|
||||
|
||||
{/* Auto-play */}
|
||||
{onToggleAutoPlay && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onToggleAutoPlay}
|
||||
className={cn(ctrlBtn, 'w-8 h-6',
|
||||
autoPlayLecture
|
||||
? 'text-violet-600 dark:text-violet-400'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
aria-label="Auto-play"
|
||||
>
|
||||
<Repeat className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{autoPlayLecture ? t('roundtable.autoPlayOff') : t('roundtable.autoPlay')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Whiteboard */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onWhiteboardClose(); }}
|
||||
className={cn(ctrlBtn, 'w-6 h-6',
|
||||
whiteboardOpen
|
||||
? 'text-violet-600 dark:text-violet-400'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
title={whiteboardOpen ? t('whiteboard.minimize') : t('whiteboard.open')}
|
||||
>
|
||||
<PencilLine className="w-3.5 h-3.5" />
|
||||
{!whiteboardOpen && whiteboardElementCount > 0 && (
|
||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-violet-500 dark:bg-violet-400 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: chat toggle ── */}
|
||||
<div className="flex items-center justify-end gap-px shrink-0 pr-1">
|
||||
{onToggleChat && (
|
||||
<button
|
||||
onClick={onToggleChat}
|
||||
className={cn(ctrlBtn, 'w-6 h-6',
|
||||
chatCollapsed
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-600 dark:text-gray-300',
|
||||
)}
|
||||
aria-label="Toggle chat"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useImperativeHandle, forwardRef, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import type { SessionType } from '@/lib/types/chat';
|
||||
import type { LectureNoteEntry } from '@/lib/types/chat';
|
||||
import type { DiscussionRequest } from '@/components/roundtable';
|
||||
import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { useStageStore } from '@/lib/store';
|
||||
import { PanelRightClose, BookOpen, MessageSquare } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useChatSessions } from './use-chat-sessions';
|
||||
import { SessionList } from './session-list';
|
||||
import { LectureNotesView } from './lecture-notes-view';
|
||||
|
||||
interface ChatAreaProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
onWidthChange?: (width: number) => void;
|
||||
collapsed?: boolean;
|
||||
onCollapseChange?: (collapsed: boolean) => void;
|
||||
activeBubbleId?: string | null;
|
||||
onActiveBubble?: (messageId: string | null) => void;
|
||||
onLiveSpeech?: (text: string | null, agentId?: string | null) => void;
|
||||
onSpeechProgress?: (ratio: number | null) => void;
|
||||
onThinking?: (state: { stage: string; agentId?: string } | null) => void;
|
||||
onCueUser?: (fromAgentId?: string, prompt?: string) => void;
|
||||
onStopSession?: () => void;
|
||||
currentSceneId?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatAreaRef {
|
||||
createSession: (type: SessionType, title: string) => Promise<string>;
|
||||
endSession: (sessionId: string) => Promise<void>;
|
||||
endActiveSession: () => Promise<void>;
|
||||
softPauseActiveSession: () => Promise<void>;
|
||||
resumeActiveSession: () => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
startDiscussion: (request: DiscussionRequest) => Promise<void>;
|
||||
startLecture: (sceneId: string) => Promise<string>;
|
||||
addLectureMessage: (sessionId: string, action: Action, actionIndex: number) => void;
|
||||
getIsStreaming: () => boolean;
|
||||
getActiveSessionType: () => string | null;
|
||||
getLectureMessageId: (sessionId: string) => string | null;
|
||||
pauseBuffer: (sessionId: string) => void;
|
||||
resumeBuffer: (sessionId: string) => void;
|
||||
switchToTab: (tab: 'lecture' | 'chat') => void;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 340;
|
||||
const MIN_WIDTH = 240;
|
||||
const MAX_WIDTH = 560;
|
||||
|
||||
export const ChatArea = forwardRef<ChatAreaRef, ChatAreaProps>(
|
||||
({ className, width = DEFAULT_WIDTH, onWidthChange, collapsed = false, onCollapseChange, activeBubbleId, onActiveBubble, onLiveSpeech, onSpeechProgress, onThinking, onCueUser, onStopSession, currentSceneId }, ref) => {
|
||||
const { t } = useI18n();
|
||||
const scenes = useStageStore((s) => s.scenes);
|
||||
const {
|
||||
sessions,
|
||||
activeSessionType,
|
||||
expandedSessionIds,
|
||||
isStreaming,
|
||||
createSession,
|
||||
endSession,
|
||||
endActiveSession,
|
||||
softPauseActiveSession,
|
||||
resumeActiveSession,
|
||||
sendMessage,
|
||||
startDiscussion,
|
||||
startLecture,
|
||||
addLectureMessage,
|
||||
toggleSessionExpand,
|
||||
getLectureMessageId,
|
||||
pauseBuffer,
|
||||
resumeBuffer,
|
||||
} = useChatSessions({ onLiveSpeech, onSpeechProgress, onThinking, onCueUser, onActiveBubble, onStopSession });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'lecture' | 'chat'>('lecture');
|
||||
const isDraggingRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derive lecture notes directly from scenes — updates reactively as scenes stream in
|
||||
// Preserves action order so spotlight/laser badges appear inline between speech texts
|
||||
const lectureNotes: LectureNoteEntry[] = useMemo(
|
||||
() => scenes
|
||||
.filter(scene => scene.actions && scene.actions.length > 0)
|
||||
.map(scene => ({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneOrder: scene.order,
|
||||
items: scene.actions!
|
||||
.filter(a => a.type === 'speech' || a.type === 'spotlight' || a.type === 'laser' || a.type === 'play_video' || a.type === 'discussion')
|
||||
.map(a => {
|
||||
if (a.type === 'speech') {
|
||||
return { kind: 'speech' as const, text: (a as SpeechAction).text };
|
||||
}
|
||||
return {
|
||||
kind: 'action' as const,
|
||||
type: a.type,
|
||||
label: a.type === 'discussion' ? (a as DiscussionAction).topic : undefined,
|
||||
};
|
||||
}),
|
||||
completedAt: scene.updatedAt || scene.createdAt || 0,
|
||||
}))
|
||||
.sort((a, b) => a.sceneOrder - b.sceneOrder),
|
||||
[scenes]
|
||||
);
|
||||
|
||||
// Filter out lecture sessions for the Chat tab
|
||||
const chatSessions = useMemo(
|
||||
() => sessions.filter(s => s.type !== 'lecture'),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
// Whether there's an active discussion/QA session (for amber dot on Chat tab)
|
||||
const hasActiveChatSession = useMemo(
|
||||
() => chatSessions.some(s => s.status === 'active'),
|
||||
[chatSessions]
|
||||
);
|
||||
|
||||
// Wrap endSession for QA/Discussion: also notify parent for engine cleanup
|
||||
const handleEndSession = useCallback(async (sessionId: string) => {
|
||||
await endSession(sessionId);
|
||||
onStopSession?.();
|
||||
}, [endSession, onStopSession]);
|
||||
|
||||
const switchToTab = useCallback((tab: 'lecture' | 'chat') => {
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createSession,
|
||||
endSession,
|
||||
endActiveSession,
|
||||
softPauseActiveSession,
|
||||
resumeActiveSession,
|
||||
sendMessage,
|
||||
startDiscussion,
|
||||
startLecture,
|
||||
addLectureMessage,
|
||||
getIsStreaming: () => isStreaming,
|
||||
getActiveSessionType: () => activeSessionType,
|
||||
getLectureMessageId,
|
||||
pauseBuffer,
|
||||
resumeBuffer,
|
||||
switchToTab,
|
||||
}));
|
||||
|
||||
// Drag-to-resize
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
const startX = e.clientX;
|
||||
const startWidth = width;
|
||||
|
||||
const handleMouseMove = (me: MouseEvent) => {
|
||||
const delta = startX - me.clientX;
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
|
||||
onWidthChange?.(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
},
|
||||
[width, onWidthChange]
|
||||
);
|
||||
|
||||
const displayWidth = collapsed ? 0 : width;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: displayWidth,
|
||||
transition: isDragging ? 'none' : 'width 0.3s ease',
|
||||
}}
|
||||
className={cn(
|
||||
'bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-l border-gray-100 dark:border-gray-800 shadow-[-2px_0_24px_rgba(0,0,0,0.02)] flex flex-col shrink-0 z-20 relative overflow-visible',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize z-50 group hover:bg-purple-400/30 dark:hover:bg-purple-600/30 active:bg-purple-500/40 dark:active:bg-purple-500/40 transition-colors"
|
||||
>
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2 w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-purple-400 dark:group-hover:bg-purple-500 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('flex flex-col w-full h-full overflow-hidden', collapsed && 'hidden')}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'lecture' | 'chat')}
|
||||
className="flex flex-col h-full gap-0"
|
||||
>
|
||||
{/* Tab header row */}
|
||||
<div className="h-10 flex items-center gap-1 shrink-0 mt-3 mb-1 px-3">
|
||||
<TabsList variant="line" className="h-full flex-1 w-0">
|
||||
<TabsTrigger value="lecture" className="text-xs gap-1 flex-1">
|
||||
<BookOpen className="w-3.5 h-3.5" />
|
||||
{t('chat.tabs.lecture')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="text-xs gap-1 flex-1 relative">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
{t('chat.tabs.chat')}
|
||||
{/* Amber pulse dot when there's an active chat session and user is on Notes tab */}
|
||||
{hasActiveChatSession && activeTab === 'lecture' && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{onCollapseChange && (
|
||||
<button
|
||||
onClick={() => onCollapseChange(true)}
|
||||
className="w-7 h-7 shrink-0 rounded-lg flex items-center justify-center bg-gray-100/80 dark:bg-gray-800/80 text-gray-500 dark:text-gray-400 ring-1 ring-black/[0.04] dark:ring-white/[0.06] hover:bg-gray-200/90 dark:hover:bg-gray-700/90 hover:text-gray-700 dark:hover:text-gray-200 active:scale-90 transition-all duration-200"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Tab */}
|
||||
<TabsContent value="lecture" className="flex-1 overflow-hidden flex flex-col">
|
||||
<LectureNotesView notes={lectureNotes} currentSceneId={currentSceneId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Chat Tab */}
|
||||
<TabsContent value="chat" className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-3 space-y-2 scrollbar-hide">
|
||||
{chatSessions.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 opacity-50">
|
||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-3 text-gray-300 dark:text-gray-600">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('chat.noConversations')}</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{t('chat.startConversation')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SessionList
|
||||
sessions={chatSessions}
|
||||
expandedSessionIds={expandedSessionIds}
|
||||
isStreaming={isStreaming}
|
||||
activeBubbleId={activeBubbleId}
|
||||
onToggleExpand={toggleSessionExpand}
|
||||
onEndSession={handleEndSession}
|
||||
/>
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChatArea.displayName = 'ChatArea';
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import type { ChatSession, ChatMessageMetadata } from '@/lib/types/chat';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { CircleStop } from 'lucide-react';
|
||||
import { InlineActionTag } from './inline-action-tag';
|
||||
import { useUserProfileStore } from '@/lib/store/user-profile';
|
||||
|
||||
/** Extended message part type covering standard + custom action parts */
|
||||
interface MessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
_partId?: string;
|
||||
actionName?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface ChatSessionProps {
|
||||
readonly session: ChatSession;
|
||||
readonly isActive: boolean;
|
||||
readonly isStreaming?: boolean;
|
||||
readonly activeBubbleId?: string | null;
|
||||
readonly onEndSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const AVATARS = {
|
||||
teacher: '/avatars/teacher.png',
|
||||
user: '/avatars/user.png',
|
||||
};
|
||||
|
||||
/** Render avatar as <img> for URLs or as emoji text span */
|
||||
function AvatarDisplay({ src, alt, className }: { src: string; alt?: string; className?: string }) {
|
||||
const isUrl = src.startsWith('http') || src.startsWith('data:') || src.startsWith('/');
|
||||
if (isUrl) {
|
||||
return <img src={src} alt={alt || ''} className={cn('w-full h-full object-cover', className)} />;
|
||||
}
|
||||
return <span className={cn('flex items-center justify-center w-full h-full select-none text-xs', className)}>{src}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageBubble — renders one message as a single chat bubble.
|
||||
*
|
||||
* Text is already paced by the StreamBuffer (30ms / 1 char) before it reaches
|
||||
* React state. No UI-layer animation is needed — we render parts directly.
|
||||
* Action badges only appear once the buffer's tick loop reaches them (after
|
||||
* all preceding text is fully revealed).
|
||||
*/
|
||||
const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
isUser,
|
||||
isTeacher,
|
||||
isStreaming,
|
||||
isLastMessage,
|
||||
isActive,
|
||||
}: {
|
||||
message: UIMessage<ChatMessageMetadata>;
|
||||
isUser: boolean;
|
||||
isTeacher: boolean;
|
||||
isStreaming: boolean;
|
||||
isLastMessage: boolean;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const parts: MessagePart[] = (message.parts || []) as MessagePart[];
|
||||
const isLive = !!(isStreaming && isLastMessage);
|
||||
|
||||
// ── Determine renderable content ──
|
||||
const hasContent = parts.some(
|
||||
(p: MessagePart) => (p.type === 'text' && p.text) || p.type?.startsWith('action-'),
|
||||
);
|
||||
|
||||
// Loading dots (between agent_start and first text_delta)
|
||||
if (!hasContent && isActive && message.role === 'assistant') {
|
||||
return (
|
||||
<div className="flex gap-1.5 items-center py-1.5 px-1">
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full animate-pulse', isTeacher ? 'bg-purple-400/70 dark:bg-purple-500/70' : 'bg-indigo-400/70 dark:bg-indigo-500/70')} />
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full animate-pulse', isTeacher ? 'bg-purple-400/70 dark:bg-purple-500/70' : 'bg-indigo-400/70 dark:bg-indigo-500/70')} style={{ animationDelay: '200ms' }} />
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full animate-pulse', isTeacher ? 'bg-purple-400/70 dark:bg-purple-500/70' : 'bg-indigo-400/70 dark:bg-indigo-500/70')} style={{ animationDelay: '400ms' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
||||
const lastTextIdx = parts.reduce(
|
||||
(acc: number, p: MessagePart, i: number) => (p.type === 'text' && p.text ? i : acc), -1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-block px-2.5 py-1.5 rounded-xl text-[12px] leading-relaxed max-w-full text-left transition-shadow duration-300',
|
||||
isUser
|
||||
? 'bg-gradient-to-br from-purple-600 to-purple-700 dark:from-purple-500 dark:to-purple-600 text-white rounded-tr-sm shadow-sm shadow-purple-300/30 dark:shadow-purple-900/50 ring-1 ring-purple-500/20'
|
||||
: isTeacher
|
||||
? 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-100 dark:border-gray-700 rounded-tl-sm shadow-sm'
|
||||
: 'bg-indigo-50 dark:bg-indigo-900/20 text-indigo-900 dark:text-indigo-200 border border-indigo-100/50 dark:border-indigo-800/50 rounded-tl-sm'
|
||||
)}
|
||||
>
|
||||
<span className="break-words">
|
||||
{parts.map((part: MessagePart, i: number) => {
|
||||
if (part.type === 'text' || part.type === 'step-start') {
|
||||
const text = part.type === 'text' ? part.text : '';
|
||||
if (!text) return null;
|
||||
|
||||
const isLast = i === lastTextIdx;
|
||||
|
||||
return (
|
||||
<span key={`${message.id}-${i}`}>
|
||||
{text}
|
||||
{isLive && isLast && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current opacity-50 animate-pulse ml-1 align-middle" />
|
||||
)}
|
||||
{message.metadata?.interrupted && isLast && !isLive && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type?.startsWith('action-')) {
|
||||
return (
|
||||
<InlineActionTag
|
||||
key={`${message.id}-action-${i}`}
|
||||
actionName={part.actionName || part.type.replace('action-', '')}
|
||||
state={part.state || 'result'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function ChatSessionComponent({ session, isActive, isStreaming, activeBubbleId, onEndSession }: ChatSessionProps) {
|
||||
const { t } = useI18n();
|
||||
const userProfileAvatar = useUserProfileStore((s) => s.avatar);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const activeBubbleRef = useRef<HTMLDivElement>(null);
|
||||
const isDiscussion = session.type === 'discussion';
|
||||
const isQA = session.type === 'qa';
|
||||
const canEnd = (isDiscussion || isQA) && session.status === 'active';
|
||||
const isEnded = session.status === 'completed' && (isDiscussion || isQA);
|
||||
|
||||
// Track whether user is at the bottom of the scroll container.
|
||||
// When user scrolls up to read history, auto-scroll is suppressed.
|
||||
const isAtBottomRef = useRef(true);
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}, []);
|
||||
|
||||
// Auto-scroll: smooth scroll when a NEW message arrives — always (new agent bubble should be visible)
|
||||
const msgCount = session.messages.length;
|
||||
useEffect(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
}, [msgCount]);
|
||||
|
||||
// Auto-scroll: rAF-throttled instant scroll as text grows — only when user is at bottom
|
||||
const scrollRaf = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!isAtBottomRef.current) return;
|
||||
cancelAnimationFrame(scrollRaf.current);
|
||||
scrollRaf.current = requestAnimationFrame(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [session.messages]);
|
||||
|
||||
// Scroll to active bubble when it changes
|
||||
useEffect(() => {
|
||||
if (activeBubbleId && activeBubbleRef.current) {
|
||||
activeBubbleRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
}, [activeBubbleId]);
|
||||
|
||||
if (session.messages.length === 0 && !isActive) {
|
||||
return (
|
||||
<div className="h-20 flex items-center justify-center text-center px-2">
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">{t('chat.noMessages')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Button text based on session type
|
||||
const endButtonText = isDiscussion ? t('chat.stopDiscussion') : t('chat.endQA');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Messages */}
|
||||
<div ref={scrollContainerRef} onScroll={handleScroll} className="space-y-1 overflow-y-auto scrollbar-hide">
|
||||
{session.messages.map((message, msgIdx) => {
|
||||
const isUser = message.metadata?.originalRole === 'user';
|
||||
const isTeacher = message.metadata?.originalRole === 'teacher';
|
||||
const avatar = isUser ? (userProfileAvatar || AVATARS.user) : message.metadata?.senderAvatar || AVATARS.teacher;
|
||||
const isActiveBubble = activeBubbleId === message.id;
|
||||
const isLastMessage = msgIdx === session.messages.length - 1;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
ref={isActiveBubble ? activeBubbleRef : undefined}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={isActiveBubble ? {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(124, 58, 237, 0)',
|
||||
'0 0 20px 0 rgba(124, 58, 237, 0.15)',
|
||||
'0 0 8px 0 rgba(124, 58, 237, 0.08)',
|
||||
],
|
||||
} : {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
boxShadow: '0 0 0 0 rgba(124, 58, 237, 0)',
|
||||
}}
|
||||
transition={isActiveBubble ? {
|
||||
boxShadow: { duration: 2.5, repeat: Infinity, ease: 'easeInOut' },
|
||||
default: { duration: 0.3 },
|
||||
} : { duration: 0.3 }}
|
||||
className={cn(
|
||||
'flex gap-2 px-1.5 py-1 rounded-lg border-l-[3px] border-l-transparent transition-[background-color,border-color] duration-300',
|
||||
isUser && 'flex-row-reverse',
|
||||
isActiveBubble && 'border-l-violet-500 dark:border-l-violet-400 bg-violet-50/50 dark:bg-violet-900/20'
|
||||
)}
|
||||
>
|
||||
{/* Mini Avatar */}
|
||||
<div className="w-5 h-5 rounded-full overflow-hidden bg-gray-100 dark:bg-gray-800 shrink-0 mt-0.5 ring-1 ring-gray-200/50 dark:ring-gray-700/50">
|
||||
<AvatarDisplay src={avatar} alt="avatar" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('flex-1 min-w-0', isUser && 'text-right')}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] font-bold uppercase tracking-wider block mb-0.5',
|
||||
isUser
|
||||
? 'text-purple-500 dark:text-purple-400'
|
||||
: isTeacher
|
||||
? 'text-purple-400 dark:text-purple-300'
|
||||
: 'text-indigo-400 dark:text-indigo-300'
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const agentId = message.metadata?.agentId;
|
||||
if (agentId) {
|
||||
const i18nName = t(`settings.agentNames.${agentId}`);
|
||||
if (i18nName !== `settings.agentNames.${agentId}`) return i18nName;
|
||||
}
|
||||
return message.metadata?.senderName || t('chat.unknown');
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<MessageBubble
|
||||
message={message}
|
||||
isUser={isUser}
|
||||
isTeacher={isTeacher}
|
||||
isStreaming={!!isStreaming}
|
||||
isLastMessage={isLastMessage}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Session ended indicator */}
|
||||
<AnimatePresence>
|
||||
{isEnded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
animate={{ opacity: 1, scaleX: 1 }}
|
||||
exit={{ opacity: 0, scaleX: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mx-3 mt-2 mb-1 flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
|
||||
<span className="flex items-center gap-1 text-[9px] text-gray-400 dark:text-gray-500 font-medium">
|
||||
<CircleStop className="w-2.5 h-2.5" />
|
||||
{t('chat.ended')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* End Session Button (for Q&A and Discussion) */}
|
||||
<AnimatePresence>
|
||||
{canEnd && onEndSession && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 5 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => onEndSession(session.id)}
|
||||
className="mt-2 mx-2 bg-red-50/80 dark:bg-red-900/20 backdrop-blur-md text-red-600 dark:text-red-400 border border-red-200/50 dark:border-red-800/50 px-3 py-1.5 rounded-full text-[11px] font-semibold flex items-center justify-center gap-1.5 transition-all shadow-sm hover:shadow-md"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 dark:bg-red-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500 dark:bg-red-400"></span>
|
||||
</span>
|
||||
{endButtonText}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Flashlight, MousePointer2, Type, Shapes, Eraser,
|
||||
PanelLeftOpen, PanelLeftClose, MessageSquare, Zap, Loader2,
|
||||
BarChart3, Sigma, Table2, PenLine, Trash2, Play, Minus,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface InlineActionTagProps {
|
||||
actionName: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// ── Style tokens ──────────────────────────────────────────────
|
||||
|
||||
const WB_STYLE = 'bg-violet-50 dark:bg-violet-500/15 border-violet-300/40 dark:border-violet-500/30 text-violet-600 dark:text-violet-300';
|
||||
const WB_ACCENT = 'bg-violet-500 dark:bg-violet-400';
|
||||
|
||||
const SPOTLIGHT_STYLE = 'bg-yellow-50 dark:bg-yellow-500/15 border-yellow-300/40 dark:border-yellow-500/30 text-yellow-700 dark:text-yellow-300';
|
||||
const LASER_STYLE = 'bg-red-50 dark:bg-red-500/15 border-red-300/40 dark:border-red-500/30 text-red-600 dark:text-red-300';
|
||||
const DISCUSS_STYLE = 'bg-amber-50 dark:bg-amber-500/15 border-amber-300/40 dark:border-amber-500/30 text-amber-700 dark:text-amber-300';
|
||||
const DEFAULT_STYLE = 'bg-gray-50 dark:bg-gray-500/15 border-gray-300/40 dark:border-gray-500/30 text-gray-600 dark:text-gray-300';
|
||||
|
||||
// ── Action config ─────────────────────────────────────────────
|
||||
|
||||
interface ActionCfg {
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
style: string;
|
||||
/** Whiteboard family — gets the pen-line accent indicator */
|
||||
wb?: boolean;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<string, ActionCfg> = {
|
||||
// Slide effects
|
||||
spotlight: { label: 'Spotlight', Icon: Flashlight, style: SPOTLIGHT_STYLE },
|
||||
laser: { label: 'Laser', Icon: MousePointer2, style: LASER_STYLE },
|
||||
play_video: { label: 'Play', Icon: Play, style: SPOTLIGHT_STYLE },
|
||||
|
||||
// Whiteboard lifecycle
|
||||
wb_open: { label: 'Open', Icon: PanelLeftOpen, style: WB_STYLE, wb: true },
|
||||
wb_close: { label: 'Close', Icon: PanelLeftClose, style: WB_STYLE, wb: true },
|
||||
wb_clear: { label: 'Clear', Icon: Eraser, style: WB_STYLE, wb: true },
|
||||
wb_delete: { label: 'Delete', Icon: Trash2, style: WB_STYLE, wb: true },
|
||||
|
||||
// Whiteboard drawing
|
||||
wb_draw_text: { label: 'Text', Icon: Type, style: WB_STYLE, wb: true },
|
||||
wb_draw_shape: { label: 'Shape', Icon: Shapes, style: WB_STYLE, wb: true },
|
||||
wb_draw_chart: { label: 'Chart', Icon: BarChart3, style: WB_STYLE, wb: true },
|
||||
wb_draw_latex: { label: 'Formula', Icon: Sigma, style: WB_STYLE, wb: true },
|
||||
wb_draw_table: { label: 'Table', Icon: Table2, style: WB_STYLE, wb: true },
|
||||
wb_draw_line: { label: 'Line', Icon: Minus, style: WB_STYLE, wb: true },
|
||||
|
||||
// Social
|
||||
discussion: { label: 'Discuss', Icon: MessageSquare, style: DISCUSS_STYLE },
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export function InlineActionTag({ actionName, state }: InlineActionTagProps) {
|
||||
const config = ACTION_CONFIG[actionName];
|
||||
const Icon = config?.Icon || Zap;
|
||||
const label = config?.label || actionName;
|
||||
const style = config?.style || DEFAULT_STYLE;
|
||||
const isWb = config?.wb ?? false;
|
||||
const isRunning = state === 'running' || state === 'input-available';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center mx-1 rounded-full border align-middle leading-none whitespace-nowrap',
|
||||
'text-[9px] font-bold tracking-wide',
|
||||
// Slightly tighter padding when wb accent is present (accent provides left visual weight)
|
||||
isWb ? 'pl-0.5 pr-1.5 py-px' : 'px-1.5 py-px',
|
||||
style,
|
||||
isRunning && 'animate-pulse',
|
||||
)}
|
||||
>
|
||||
{/* Whiteboard accent: tiny PenLine chip on the left */}
|
||||
{isWb && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full mr-0.5 shrink-0',
|
||||
'w-3 h-3',
|
||||
WB_ACCENT,
|
||||
)}
|
||||
>
|
||||
<PenLine className="w-[7px] h-[7px] text-white dark:text-violet-950" strokeWidth={2.5} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action icon */}
|
||||
{isRunning
|
||||
? <Loader2 className="w-2.5 h-2.5 animate-spin shrink-0" />
|
||||
: <Icon className="w-2.5 h-2.5 shrink-0" />
|
||||
}
|
||||
|
||||
<span className="ml-0.5">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { BookOpen, MessageSquare, Flashlight, MousePointer2, Play } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import type { LectureNoteEntry } from '@/lib/types/chat';
|
||||
|
||||
const ACTION_ICON_ONLY: Record<string, { Icon: typeof Flashlight; style: string }> = {
|
||||
spotlight: { Icon: Flashlight, style: 'bg-yellow-50 dark:bg-yellow-500/15 border-yellow-300/40 dark:border-yellow-500/30 text-yellow-700 dark:text-yellow-300' },
|
||||
laser: { Icon: MousePointer2, style: 'bg-red-50 dark:bg-red-500/15 border-red-300/40 dark:border-red-500/30 text-red-600 dark:text-red-300' },
|
||||
play_video: { Icon: Play, style: 'bg-yellow-50 dark:bg-yellow-500/15 border-yellow-300/40 dark:border-yellow-500/30 text-yellow-700 dark:text-yellow-300' },
|
||||
};
|
||||
|
||||
interface LectureNotesViewProps {
|
||||
notes: LectureNoteEntry[];
|
||||
currentSceneId?: string | null;
|
||||
}
|
||||
|
||||
export function LectureNotesView({ notes, currentSceneId }: LectureNotesViewProps) {
|
||||
const { t } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to the current scene note
|
||||
useEffect(() => {
|
||||
if (!currentSceneId || !containerRef.current) return;
|
||||
const el = containerRef.current.querySelector(`[data-scene-id="${currentSceneId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [currentSceneId]);
|
||||
|
||||
// Empty state
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="w-12 h-12 bg-purple-50 dark:bg-purple-900/20 rounded-2xl flex items-center justify-center mb-3 text-purple-300 dark:text-purple-600 ring-1 ring-purple-100 dark:ring-purple-800/30">
|
||||
<BookOpen className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('chat.lectureNotes.empty')}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{t('chat.lectureNotes.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-2 scrollbar-hide">
|
||||
{notes.map((note, index) => {
|
||||
const isCurrent = note.sceneId === currentSceneId;
|
||||
const pageNum = index + 1;
|
||||
const pageLabel = t('chat.lectureNotes.pageLabel').replace('{n}', String(pageNum));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={note.sceneId}
|
||||
data-scene-id={note.sceneId}
|
||||
className={cn(
|
||||
'relative mb-3 last:mb-0 rounded-lg px-3 py-2.5 transition-colors duration-200',
|
||||
isCurrent
|
||||
? 'bg-purple-50/80 dark:bg-purple-950/25 ring-1 ring-purple-200/60 dark:ring-purple-700/30'
|
||||
: 'bg-gray-50/50 dark:bg-gray-800/30'
|
||||
)}
|
||||
>
|
||||
{/* Page label row */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
isCurrent
|
||||
? 'bg-purple-500 dark:bg-purple-400 shadow-sm shadow-purple-400/40'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold tracking-wide',
|
||||
isCurrent ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400 dark:text-gray-500'
|
||||
)}>
|
||||
{pageLabel}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[9px] font-bold px-1.5 py-px rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-600 dark:text-purple-300">
|
||||
{t('chat.lectureNotes.currentPage')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scene title */}
|
||||
<h4 className="text-[13px] font-bold text-gray-800 dark:text-gray-100 mb-1.5 leading-snug pl-4">
|
||||
{note.sceneTitle}
|
||||
</h4>
|
||||
|
||||
{/* Ordered items: spotlight/laser inline at sentence start, discussion as card */}
|
||||
<div className="pl-4 space-y-1">
|
||||
{(() => {
|
||||
// Build render rows: group inline actions (spotlight/laser) with next speech,
|
||||
// but render discussion as its own block
|
||||
type Row =
|
||||
| { kind: 'speech'; inlineActions: string[]; text: string }
|
||||
| { kind: 'discussion'; label?: string }
|
||||
| { kind: 'trailing'; inlineActions: string[] };
|
||||
const rows: Row[] = [];
|
||||
let pendingInline: string[] = [];
|
||||
for (const item of note.items) {
|
||||
if (item.kind === 'action' && item.type === 'discussion') {
|
||||
// Flush pending inline actions as trailing if any
|
||||
if (pendingInline.length > 0) {
|
||||
rows.push({ kind: 'trailing', inlineActions: pendingInline });
|
||||
pendingInline = [];
|
||||
}
|
||||
rows.push({ kind: 'discussion', label: item.label });
|
||||
} else if (item.kind === 'action') {
|
||||
pendingInline.push(item.type);
|
||||
} else {
|
||||
rows.push({ kind: 'speech', inlineActions: pendingInline, text: item.text });
|
||||
pendingInline = [];
|
||||
}
|
||||
}
|
||||
if (pendingInline.length > 0) {
|
||||
rows.push({ kind: 'trailing', inlineActions: pendingInline });
|
||||
}
|
||||
return rows.map((row, i) => {
|
||||
if (row.kind === 'discussion') {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="my-1.5 flex items-start gap-1.5 rounded-md border border-amber-200/60 dark:border-amber-700/30 bg-amber-50/60 dark:bg-amber-900/10 px-2 py-1.5"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3 text-amber-500 dark:text-amber-400 shrink-0 mt-0.5" />
|
||||
<span className="text-[11px] leading-snug text-amber-800 dark:text-amber-300">
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const actions = row.kind === 'trailing' ? row.inlineActions : row.inlineActions;
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className="text-[12px] leading-[1.8] text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{actions.map((a, j) => {
|
||||
const cfg = ACTION_ICON_ONLY[a];
|
||||
if (!cfg) return null;
|
||||
const { Icon, style } = cfg;
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-4 h-4 rounded-full border align-middle mr-0.5',
|
||||
style,
|
||||
)}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{row.kind === 'speech' ? row.text : null}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { StatelessEvent } from '@/lib/types/chat';
|
||||
import type { StreamBuffer } from '@/lib/buffer/stream-buffer';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('SSEStream');
|
||||
|
||||
/**
|
||||
* Thin SSE parser — reads the /api/chat response stream and pushes
|
||||
* typed events into a StreamBuffer. All pacing, state management,
|
||||
* and UI updates are handled by the buffer's tick loop and callbacks.
|
||||
*/
|
||||
export async function processSSEStream(
|
||||
response: Response,
|
||||
sessionId: string,
|
||||
buffer: StreamBuffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
let currentMessageId: string | null = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
sseBuffer += chunk;
|
||||
|
||||
// Process complete SSE events (split on double newline)
|
||||
const events = sseBuffer.split('\n\n');
|
||||
sseBuffer = events.pop() || '';
|
||||
|
||||
for (const eventStr of events) {
|
||||
const line = eventStr.trim();
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
let sseError: Error | null = null;
|
||||
|
||||
try {
|
||||
const event: StatelessEvent = JSON.parse(line.slice(6));
|
||||
|
||||
switch (event.type) {
|
||||
case 'agent_start': {
|
||||
const { messageId, agentId, agentName, agentAvatar, agentColor } = event.data;
|
||||
currentMessageId = messageId;
|
||||
buffer.pushAgentStart({ messageId, agentId, agentName, avatar: agentAvatar, color: agentColor });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
buffer.pushAgentEnd({ messageId: event.data.messageId, agentId: event.data.agentId });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text_delta': {
|
||||
const targetId = event.data.messageId ?? currentMessageId;
|
||||
if (!targetId) break;
|
||||
buffer.pushText(targetId, event.data.content);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'action': {
|
||||
const targetId = event.data.messageId ?? currentMessageId;
|
||||
if (!targetId) break;
|
||||
if (signal?.aborted) break;
|
||||
buffer.pushAction({
|
||||
messageId: targetId,
|
||||
actionId: event.data.actionId,
|
||||
actionName: event.data.actionName,
|
||||
params: event.data.params,
|
||||
agentId: event.data.agentId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
buffer.pushThinking(event.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cue_user': {
|
||||
buffer.pushCueUser(event.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
buffer.pushDone(event.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
sseError = new Error(event.data.message);
|
||||
buffer.pushError(event.data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
log.warn('[SSE] Parse error:', parseError);
|
||||
}
|
||||
|
||||
if (sseError) throw sseError;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import type { ChatSession, SessionStatus } from '@/lib/types/chat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { ChevronDown, Circle, CheckCircle, Clock } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { ChatSessionComponent } from './chat-session';
|
||||
|
||||
interface SessionListProps {
|
||||
sessions: ChatSession[];
|
||||
expandedSessionIds: Set<string>;
|
||||
isStreaming: boolean;
|
||||
activeBubbleId?: string | null;
|
||||
onToggleExpand: (sessionId: string) => void;
|
||||
onEndSession: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const sessionBadgeStyles = {
|
||||
qa: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
discussion: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
lecture: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
};
|
||||
|
||||
// Labels are provided via i18n in the component
|
||||
|
||||
function getStatusIcon(status: SessionStatus) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Circle className="size-2.5 fill-green-500 text-green-500" />;
|
||||
case 'interrupted':
|
||||
return <Clock className="size-2.5 text-yellow-500" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="size-2.5 text-gray-400" />;
|
||||
case 'idle':
|
||||
default:
|
||||
return <Circle className="size-2.5 text-gray-300" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
expandedSessionIds,
|
||||
isStreaming,
|
||||
activeBubbleId,
|
||||
onToggleExpand,
|
||||
onEndSession,
|
||||
}: SessionListProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
{sessions.map((session) => {
|
||||
const isExpanded = expandedSessionIds.has(session.id);
|
||||
const isActive = session.status === 'active';
|
||||
const dotColor = session.type === 'lecture' ? 'bg-purple-500' : session.type === 'qa' ? 'bg-blue-500' : 'bg-amber-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'rounded-xl border transition-all duration-500 overflow-hidden',
|
||||
isActive
|
||||
? 'border-purple-200 dark:border-purple-700 bg-purple-50/30 dark:bg-purple-900/20 shadow-sm'
|
||||
: 'border-gray-100 dark:border-gray-800 bg-white/50 dark:bg-gray-800/50'
|
||||
)}
|
||||
>
|
||||
{/* Session Header */}
|
||||
<button
|
||||
onClick={() => onToggleExpand(session.id)}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-2 text-left hover:bg-gray-50/50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className={cn(dotColor, 'relative inline-flex rounded-full h-2.5 w-2.5')} />
|
||||
{isActive && (
|
||||
<span
|
||||
className={cn(
|
||||
dotColor,
|
||||
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[8px] font-extrabold uppercase tracking-wider px-1.5 py-px rounded shrink-0',
|
||||
sessionBadgeStyles[session.type]
|
||||
)}
|
||||
>
|
||||
{t(`chat.badge.${session.type}`)}
|
||||
</span>
|
||||
<span className="flex-1 text-[11px] font-semibold text-gray-700 dark:text-gray-300 truncate">
|
||||
{session.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-[9px] text-gray-400 dark:text-gray-500">
|
||||
{getStatusIcon(session.status)}
|
||||
</div>
|
||||
<span className="text-[9px] text-gray-400 dark:text-gray-500 font-medium tabular-nums shrink-0">
|
||||
{session.messages.length}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 transition-transform duration-200 shrink-0',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Messages */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden border-t border-gray-100/50 dark:border-gray-700/50"
|
||||
>
|
||||
<div className="px-2 pb-2 pt-1">
|
||||
<ChatSessionComponent
|
||||
session={session}
|
||||
isActive={isActive}
|
||||
isStreaming={isStreaming && isActive}
|
||||
activeBubbleId={activeBubbleId}
|
||||
onEndSession={onEndSession}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Globe,
|
||||
Paperclip,
|
||||
FileText,
|
||||
X,
|
||||
Globe2,
|
||||
} from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
|
||||
import type { PDFProviderId } from '@/lib/pdf/types';
|
||||
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
|
||||
import type { WebSearchProviderId } from '@/lib/web-search/types';
|
||||
import type { ProviderId } from '@/lib/ai/providers';
|
||||
import type { SettingsSection } from '@/lib/types/settings';
|
||||
import { MediaPopover } from '@/components/media-popover';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────
|
||||
const MAX_PDF_SIZE_MB = 50;
|
||||
const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
export interface GenerationToolbarProps {
|
||||
language: 'zh-CN' | 'en-US';
|
||||
onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
|
||||
webSearch: boolean;
|
||||
onWebSearchChange: (v: boolean) => void;
|
||||
onSettingsOpen: (section?: SettingsSection) => void;
|
||||
// PDF
|
||||
pdfFile: File | null;
|
||||
onPdfFileChange: (file: File | null) => void;
|
||||
onPdfError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────
|
||||
export function GenerationToolbar({
|
||||
language,
|
||||
onLanguageChange,
|
||||
webSearch,
|
||||
onWebSearchChange,
|
||||
onSettingsOpen,
|
||||
pdfFile,
|
||||
onPdfFileChange,
|
||||
onPdfError,
|
||||
}: GenerationToolbarProps) {
|
||||
const { t } = useI18n();
|
||||
const currentProviderId = useSettingsStore((s) => s.providerId);
|
||||
const currentModelId = useSettingsStore((s) => s.modelId);
|
||||
const providersConfig = useSettingsStore((s) => s.providersConfig);
|
||||
const setModel = useSettingsStore((s) => s.setModel);
|
||||
const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
|
||||
const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig);
|
||||
const setPDFProvider = useSettingsStore((s) => s.setPDFProvider);
|
||||
const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId);
|
||||
const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig);
|
||||
const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Check if the selected web search provider has a valid config (API key or server-configured)
|
||||
const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
|
||||
const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
|
||||
const webSearchAvailable = webSearchProvider
|
||||
? !webSearchProvider.requiresApiKey || !!webSearchConfig?.apiKey || !!webSearchConfig?.isServerConfigured
|
||||
: false;
|
||||
|
||||
// Configured LLM providers (only those with valid credentials + models + endpoint)
|
||||
const configuredProviders = providersConfig
|
||||
? Object.entries(providersConfig)
|
||||
.filter(
|
||||
([, config]) =>
|
||||
(!config.requiresApiKey || config.apiKey || config.isServerConfigured) &&
|
||||
config.models.length >= 1 &&
|
||||
(config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl)
|
||||
)
|
||||
.map(([id, config]) => ({
|
||||
id: id as ProviderId,
|
||||
name: config.name,
|
||||
icon: config.icon,
|
||||
isServerConfigured: config.isServerConfigured,
|
||||
models: config.isServerConfigured && !config.apiKey && config.serverModels?.length
|
||||
? config.models.filter((m) => new Set(config.serverModels).has(m.id))
|
||||
: config.models,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const currentProviderConfig = providersConfig?.[currentProviderId];
|
||||
|
||||
// PDF handler
|
||||
const handleFileSelect = (file: File) => {
|
||||
if (file.type !== 'application/pdf') return;
|
||||
if (file.size > MAX_PDF_SIZE_BYTES) {
|
||||
onPdfError(t('upload.fileTooLarge'));
|
||||
return;
|
||||
}
|
||||
onPdfError(null);
|
||||
onPdfFileChange(file);
|
||||
};
|
||||
|
||||
// ─── Pill button helper ─────────────────────────────
|
||||
const pillCls =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border';
|
||||
const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`;
|
||||
const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{/* ── Model selector ── */}
|
||||
{configuredProviders.length > 0 ? (
|
||||
<ModelSelectorPopover
|
||||
configuredProviders={configuredProviders}
|
||||
currentProviderId={currentProviderId}
|
||||
currentModelId={currentModelId}
|
||||
currentProviderConfig={currentProviderConfig}
|
||||
setModel={setModel}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onSettingsOpen('providers')}
|
||||
className={cn(
|
||||
pillCls,
|
||||
'text-amber-600 dark:text-amber-400 animate-pulse',
|
||||
'bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-3.5" />
|
||||
<span>{t('toolbar.configureProvider')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('toolbar.configureProviderHint')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* ── Separator ── */}
|
||||
<div className="w-px h-4 bg-border/60 mx-1" />
|
||||
|
||||
{/* ── PDF (parser + upload) combined Popover ── */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{pdfFile ? (
|
||||
<button className={pillActive}>
|
||||
<Paperclip className="size-3.5" />
|
||||
<span className="max-w-[100px] truncate">{pdfFile.name}</span>
|
||||
<span
|
||||
role="button"
|
||||
className="size-4 rounded-full inline-flex items-center justify-center hover:bg-violet-200 dark:hover:bg-violet-800 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPdfFileChange(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className={pillMuted}>
|
||||
<Paperclip className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-0">
|
||||
{/* Parser selector */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
||||
{t('toolbar.pdfParser')}
|
||||
</span>
|
||||
<Select
|
||||
value={pdfProviderId}
|
||||
onValueChange={(v) => setPDFProvider(v as PDFProviderId)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(PDF_PROVIDERS).map((provider) => {
|
||||
const cfg = pdfProvidersConfig[provider.id];
|
||||
const available = !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
|
||||
return (
|
||||
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
|
||||
<div className={cn('flex items-center gap-1.5', !available && 'opacity-50')}>
|
||||
{provider.icon && (
|
||||
<img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{provider.name}
|
||||
{cfg?.isServerConfigured && (
|
||||
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
|
||||
{t('settings.serverConfigured')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Upload area / file info */}
|
||||
<div className="px-3 pb-3">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFileSelect(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{pdfFile ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
|
||||
<FileText className="size-4 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{pdfFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(pdfFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPdfFileChange(null)}
|
||||
className="w-full text-xs text-destructive hover:underline text-left"
|
||||
>
|
||||
{t('toolbar.removePdf')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer',
|
||||
isDragging
|
||||
? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20'
|
||||
: 'border-muted-foreground/20 hover:border-violet-300'
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) handleFileSelect(f);
|
||||
}}
|
||||
>
|
||||
<Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
|
||||
<p className="text-xs font-medium">{t('toolbar.pdfUpload')}</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
{t('upload.pdfSizeLimit')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* ── Web Search ── */}
|
||||
{webSearchAvailable ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={webSearch ? pillActive : pillMuted}>
|
||||
<Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} />
|
||||
{webSearch && (
|
||||
<span>{WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || 'Search'}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-3 space-y-3">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => onWebSearchChange(!webSearch)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all',
|
||||
webSearch
|
||||
? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<Globe2 className={cn(
|
||||
'size-4 shrink-0',
|
||||
webSearch ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground'
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium">
|
||||
{webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
{t('toolbar.webSearchDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Provider selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
||||
{t('toolbar.webSearchProvider')}
|
||||
</span>
|
||||
<Select
|
||||
value={webSearchProviderId}
|
||||
onValueChange={(v) => setWebSearchProvider(v as WebSearchProviderId)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(WEB_SEARCH_PROVIDERS).map((provider) => {
|
||||
const cfg = webSearchProvidersConfig[provider.id];
|
||||
const available = !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
|
||||
return (
|
||||
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
|
||||
<div className={cn('flex items-center gap-1.5', !available && 'opacity-50')}>
|
||||
{provider.name}
|
||||
{cfg?.isServerConfigured && (
|
||||
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
|
||||
{t('settings.serverConfigured')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(pillCls, 'text-muted-foreground/40 cursor-not-allowed')}
|
||||
disabled
|
||||
>
|
||||
<Globe2 className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('toolbar.webSearchNoProvider')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* ── Language pill ── */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
|
||||
className={pillMuted}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* ── Separator ── */}
|
||||
<div className="w-px h-4 bg-border/60 mx-1" />
|
||||
|
||||
{/* ── Media popover ── */}
|
||||
<MediaPopover onSettingsOpen={onSettingsOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ModelSelectorPopover (two-level: provider → model) ─────
|
||||
interface ConfiguredProvider {
|
||||
id: ProviderId;
|
||||
name: string;
|
||||
icon?: string;
|
||||
isServerConfigured?: boolean;
|
||||
models: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
function ModelSelectorPopover({
|
||||
configuredProviders,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentProviderConfig,
|
||||
setModel,
|
||||
t,
|
||||
}: {
|
||||
configuredProviders: ConfiguredProvider[];
|
||||
currentProviderId: ProviderId;
|
||||
currentModelId: string;
|
||||
currentProviderConfig: { name: string; icon?: string } | undefined;
|
||||
setModel: (providerId: ProviderId, modelId: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
// null = provider list, ProviderId = model list for that provider
|
||||
const [drillProvider, setDrillProvider] = useState<ProviderId | null>(null);
|
||||
|
||||
const activeProvider = useMemo(
|
||||
() => configuredProviders.find((p) => p.id === drillProvider),
|
||||
[configuredProviders, drillProvider]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={popoverOpen} onOpenChange={(open) => { setPopoverOpen(open); if (open) setDrillProvider(null); }}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-7 rounded-full transition-all cursor-pointer select-none',
|
||||
'ring-1 ring-border/60 hover:ring-border hover:bg-muted/60',
|
||||
currentModelId && 'ring-violet-300 dark:ring-violet-700 bg-violet-50 dark:bg-violet-950/20'
|
||||
)}
|
||||
>
|
||||
{currentProviderConfig?.icon ? (
|
||||
<img
|
||||
src={currentProviderConfig.icon}
|
||||
alt={currentProviderConfig.name}
|
||||
className="size-4 rounded-sm"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentModelId
|
||||
? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`
|
||||
: t('settings.selectModel')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
{/* Level 1: Provider list */}
|
||||
{!drillProvider && (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<div className="px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold text-muted-foreground">{t('toolbar.selectProvider')}</span>
|
||||
</div>
|
||||
{configuredProviders.map((provider) => {
|
||||
const isActive = currentProviderId === provider.id;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => setDrillProvider(provider.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-border/30',
|
||||
isActive ? 'bg-violet-50/50 dark:bg-violet-950/10' : 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{provider.icon ? (
|
||||
<img src={provider.icon} alt={provider.name} className="size-5 rounded-sm shrink-0" />
|
||||
) : (
|
||||
<Bot className="size-5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
{provider.isServerConfigured && (
|
||||
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground ml-1.5">
|
||||
{t('settings.serverConfigured')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isActive && currentModelId && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{currentModelId}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level 2: Model list for selected provider */}
|
||||
{drillProvider && activeProvider && (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{/* Back header */}
|
||||
<button
|
||||
onClick={() => setDrillProvider(null)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b bg-muted/40 hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 text-muted-foreground" />
|
||||
{activeProvider.icon ? (
|
||||
<img src={activeProvider.icon} alt={activeProvider.name} className="size-4 rounded-sm" />
|
||||
) : (
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-semibold">{activeProvider.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||||
{activeProvider.models.length} {t('settings.modelCount')}
|
||||
</span>
|
||||
</button>
|
||||
{/* Models */}
|
||||
{activeProvider.models.map((model) => {
|
||||
const isSelected = currentProviderId === drillProvider && currentModelId === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { setModel(drillProvider, model.id); setPopoverOpen(false); }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-border/30',
|
||||
isSelected
|
||||
? 'bg-violet-50 dark:bg-violet-950/20 text-violet-700 dark:text-violet-300'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate font-mono text-xs">{model.name}</span>
|
||||
{isSelected && <Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, CheckCircle2, XCircle, Circle } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
|
||||
interface GeneratingProgressProps {
|
||||
outlineReady: boolean; // Is outline generation complete?
|
||||
firstPageReady: boolean; // Is first page generated?
|
||||
statusMessage: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Status item component - declared outside main component
|
||||
function StatusItem({
|
||||
completed,
|
||||
inProgress,
|
||||
hasError,
|
||||
label,
|
||||
}: {
|
||||
completed: boolean;
|
||||
inProgress: boolean;
|
||||
hasError: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="flex-shrink-0">
|
||||
{hasError ? (
|
||||
<XCircle className="size-6 text-destructive" />
|
||||
) : completed ? (
|
||||
<CheckCircle2 className="size-6 text-green-500" />
|
||||
) : inProgress ? (
|
||||
<Loader2 className="size-6 text-primary animate-spin" />
|
||||
) : (
|
||||
<Circle className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-base ${
|
||||
hasError
|
||||
? 'text-destructive'
|
||||
: completed
|
||||
? 'text-green-600 font-medium'
|
||||
: inProgress
|
||||
? 'text-primary font-medium'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneratingProgress({
|
||||
outlineReady,
|
||||
firstPageReady,
|
||||
statusMessage,
|
||||
error,
|
||||
}: GeneratingProgressProps) {
|
||||
const { t } = useI18n();
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
// Animated dots for loading state
|
||||
useEffect(() => {
|
||||
if (!error && !firstPageReady) {
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [error, firstPageReady]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{error ? (
|
||||
<>
|
||||
<XCircle className="size-5 text-destructive" />
|
||||
{t('generation.generationFailed')}
|
||||
</>
|
||||
) : firstPageReady ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-5 text-green-500" />
|
||||
{t('generation.openingClassroom')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('generation.generatingCourse')}{dots}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Two milestone status items */}
|
||||
<div className="divide-y">
|
||||
<StatusItem
|
||||
completed={outlineReady}
|
||||
inProgress={!outlineReady && !error}
|
||||
hasError={!outlineReady && !!error}
|
||||
label={
|
||||
outlineReady
|
||||
? t('generation.outlineReady')
|
||||
: t('generation.generatingOutlines')
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
completed={firstPageReady}
|
||||
inProgress={outlineReady && !firstPageReady && !error}
|
||||
hasError={outlineReady && !firstPageReady && !!error}
|
||||
label={
|
||||
firstPageReady
|
||||
? t('generation.firstPageReady')
|
||||
: t('generation.generatingFirstPage')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{statusMessage && !error && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-sm text-muted-foreground">{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { SceneOutline } from '@/lib/types/generation';
|
||||
|
||||
interface OutlinesEditorProps {
|
||||
outlines: SceneOutline[];
|
||||
onChange: (outlines: SceneOutline[]) => void;
|
||||
onConfirm: () => void;
|
||||
onBack: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function OutlinesEditor({
|
||||
outlines,
|
||||
onChange,
|
||||
onConfirm,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: OutlinesEditorProps) {
|
||||
const addOutline = () => {
|
||||
const newOutline: SceneOutline = {
|
||||
id: nanoid(8),
|
||||
type: 'slide',
|
||||
title: '',
|
||||
description: '',
|
||||
keyPoints: [],
|
||||
order: outlines.length + 1,
|
||||
};
|
||||
onChange([...outlines, newOutline]);
|
||||
};
|
||||
|
||||
const updateOutline = (index: number, updates: Partial<SceneOutline>) => {
|
||||
const newOutlines = [...outlines];
|
||||
newOutlines[index] = { ...newOutlines[index], ...updates };
|
||||
onChange(newOutlines);
|
||||
};
|
||||
|
||||
const removeOutline = (index: number) => {
|
||||
const newOutlines = outlines.filter((_, i) => i !== index);
|
||||
// Update order
|
||||
newOutlines.forEach((outline, i) => {
|
||||
outline.order = i + 1;
|
||||
});
|
||||
onChange(newOutlines);
|
||||
};
|
||||
|
||||
const moveOutline = (index: number, direction: 'up' | 'down') => {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= outlines.length) return;
|
||||
const newOutlines = [...outlines];
|
||||
[newOutlines[index], newOutlines[newIndex]] = [newOutlines[newIndex], newOutlines[index]];
|
||||
// Update order
|
||||
newOutlines.forEach((outline, i) => {
|
||||
outline.order = i + 1;
|
||||
});
|
||||
onChange(newOutlines);
|
||||
};
|
||||
|
||||
const updateKeyPoints = (index: number, keyPointsText: string) => {
|
||||
const keyPoints = keyPointsText
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
updateOutline(index, { keyPoints });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">场景大纲</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
共 {outlines.length} 个场景,可编辑、添加、删除或重排序
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={addOutline} disabled={isLoading}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
添加场景
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{outlines.map((outline, index) => (
|
||||
<Card key={outline.id} className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => moveOutline(index, 'up')}
|
||||
disabled={index === 0 || isLoading}
|
||||
className="size-6"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => moveOutline(index, 'down')}
|
||||
disabled={index === outlines.length - 1 || isLoading}
|
||||
className="size-6"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<span className="bg-primary text-primary-foreground size-6 rounded-full flex items-center justify-center text-sm">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Input
|
||||
value={outline.title}
|
||||
onChange={(e) => updateOutline(index, { title: e.target.value })}
|
||||
placeholder="场景标题"
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Select
|
||||
value={outline.type}
|
||||
onValueChange={(value) =>
|
||||
updateOutline(index, { type: value as SceneOutline['type'] })
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="slide">幻灯片</SelectItem>
|
||||
<SelectItem value="quiz">测验</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeOutline(index)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>场景描述</Label>
|
||||
<Textarea
|
||||
value={outline.description}
|
||||
onChange={(e) => updateOutline(index, { description: e.target.value })}
|
||||
placeholder="简短描述这个场景的目的和内容"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>关键要点(每行一个)</Label>
|
||||
<Textarea
|
||||
value={outline.keyPoints?.join('\n') || ''}
|
||||
onChange={(e) => updateKeyPoints(index, e.target.value)}
|
||||
placeholder="输入关键要点,每行一个"
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{outline.type === 'quiz' && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg space-y-3">
|
||||
<Label className="text-sm font-medium">测验配置</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">题目数量</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={outline.quizConfig?.questionCount || 3}
|
||||
onChange={(e) =>
|
||||
updateOutline(index, {
|
||||
quizConfig: {
|
||||
...outline.quizConfig,
|
||||
questionCount: parseInt(e.target.value) || 3,
|
||||
difficulty: outline.quizConfig?.difficulty || 'medium',
|
||||
questionTypes: outline.quizConfig?.questionTypes || ['single'],
|
||||
},
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">难度</Label>
|
||||
<Select
|
||||
value={outline.quizConfig?.difficulty || 'medium'}
|
||||
onValueChange={(value) =>
|
||||
updateOutline(index, {
|
||||
quizConfig: {
|
||||
...outline.quizConfig,
|
||||
difficulty: value as 'easy' | 'medium' | 'hard',
|
||||
questionCount: outline.quizConfig?.questionCount || 3,
|
||||
questionTypes: outline.quizConfig?.questionTypes || ['single'],
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">简单</SelectItem>
|
||||
<SelectItem value="medium">中等</SelectItem>
|
||||
<SelectItem value="hard">困难</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">题型</Label>
|
||||
<Select
|
||||
value={outline.quizConfig?.questionTypes?.[0] || 'single'}
|
||||
onValueChange={(value) =>
|
||||
updateOutline(index, {
|
||||
quizConfig: {
|
||||
...outline.quizConfig,
|
||||
questionTypes: [value as 'single' | 'multiple' | 'text'],
|
||||
questionCount: outline.quizConfig?.questionCount || 3,
|
||||
difficulty: outline.quizConfig?.difficulty || 'medium',
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single">单选</SelectItem>
|
||||
<SelectItem value="multiple">多选</SelectItem>
|
||||
<SelectItem value="text">简答</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{outlines.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">暂无场景大纲</p>
|
||||
<Button variant="outline" onClick={addOutline} disabled={isLoading}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
添加第一个场景
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} disabled={isLoading}>
|
||||
返回修改需求
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={isLoading || outlines.length === 0}>
|
||||
{isLoading ? '生成中...' : '确认并生成课程'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, Sun, Moon, Monitor, ArrowLeft, Loader2, Download, FileDown, Package } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { useTheme } from '@/lib/use-theme';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SettingsDialog } from './settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useStageStore } from '@/lib/store/stage';
|
||||
import { useMediaGenerationStore } from '@/lib/store/media-generation';
|
||||
import { useExportPPTX } from '@/lib/export/use-export-pptx';
|
||||
|
||||
interface HeaderProps {
|
||||
readonly currentSceneTitle: string;
|
||||
}
|
||||
|
||||
export function Header({ currentSceneTitle }: HeaderProps) {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [languageOpen, setLanguageOpen] = useState(false);
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
|
||||
// Model setup state
|
||||
const currentModelId = useSettingsStore((s) => s.modelId);
|
||||
const needsSetup = !currentModelId;
|
||||
|
||||
// Export
|
||||
const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
const scenes = useStageStore((s) => s.scenes);
|
||||
const generatingOutlines = useStageStore((s) => s.generatingOutlines);
|
||||
const failedOutlines = useStageStore((s) => s.failedOutlines);
|
||||
const mediaTasks = useMediaGenerationStore((s) => s.tasks);
|
||||
|
||||
const canExport =
|
||||
scenes.length > 0 &&
|
||||
generatingOutlines.length === 0 &&
|
||||
failedOutlines.length === 0 &&
|
||||
Object.values(mediaTasks).every(
|
||||
(task) => task.status === 'done' || task.status === 'failed',
|
||||
);
|
||||
|
||||
const languageRef = useRef<HTMLDivElement>(null);
|
||||
const themeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = useCallback((e: MouseEvent) => {
|
||||
if (languageOpen && languageRef.current && !languageRef.current.contains(e.target as Node)) {
|
||||
setLanguageOpen(false);
|
||||
}
|
||||
if (themeOpen && themeRef.current && !themeRef.current.contains(e.target as Node)) {
|
||||
setThemeOpen(false);
|
||||
}
|
||||
if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) {
|
||||
setExportMenuOpen(false);
|
||||
}
|
||||
}, [languageOpen, themeOpen, exportMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (languageOpen || themeOpen || exportMenuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [languageOpen, themeOpen, exportMenuOpen, handleClickOutside]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-20 px-8 flex items-center justify-between z-10 bg-transparent gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="shrink-0 p-2 rounded-lg text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
title={t('generation.backToHome')}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold text-gray-400 dark:text-gray-500 mb-0.5">
|
||||
{t('stage.currentScene')}
|
||||
</span>
|
||||
<h1
|
||||
className="text-xl font-bold text-gray-800 dark:text-gray-200 tracking-tight truncate"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{currentSceneTitle || t('common.loading')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm shrink-0">
|
||||
{/* Language Selector */}
|
||||
<div className="relative" ref={languageRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLanguageOpen(!languageOpen);
|
||||
setThemeOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
|
||||
>
|
||||
{locale === 'zh-CN' ? 'CN' : 'EN'}
|
||||
</button>
|
||||
{languageOpen && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocale('zh-CN');
|
||||
setLanguageOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||
locale === 'zh-CN' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
简体中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocale('en-US');
|
||||
setLanguageOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||
locale === 'en-US' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
{/* Theme Selector */}
|
||||
<div className="relative" ref={themeRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setThemeOpen(!themeOpen);
|
||||
setLanguageOpen(false);
|
||||
}}
|
||||
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
|
||||
>
|
||||
{theme === 'light' && <Sun className="w-4 h-4" />}
|
||||
{theme === 'dark' && <Moon className="w-4 h-4" />}
|
||||
{theme === 'system' && <Monitor className="w-4 h-4" />}
|
||||
</button>
|
||||
{themeOpen && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme('light');
|
||||
setThemeOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'light' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
{t('settings.themeOptions.light')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme('dark');
|
||||
setThemeOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'dark' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
{t('settings.themeOptions.dark')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme('system');
|
||||
setThemeOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
theme === 'system' && 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t('settings.themeOptions.system')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
{/* Settings Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={cn(
|
||||
"p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group",
|
||||
needsSetup && "animate-setup-glow"
|
||||
)}
|
||||
>
|
||||
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
|
||||
</button>
|
||||
{needsSetup && (
|
||||
<>
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-3 w-3">
|
||||
<span className="animate-setup-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
|
||||
</span>
|
||||
<span className="animate-setup-float absolute top-full mt-2 right-0 whitespace-nowrap text-[11px] font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/50 px-2 py-0.5 rounded-full shadow-sm pointer-events-none">
|
||||
{t('settings.setupNeeded')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Export Dropdown */}
|
||||
<div className="relative" ref={exportRef}>
|
||||
<button
|
||||
onClick={() => { if (canExport && !isExporting) setExportMenuOpen(!exportMenuOpen); }}
|
||||
disabled={!canExport || isExporting}
|
||||
title={canExport ? (isExporting ? t('export.exporting') : t('export.pptx')) : t('share.notReady')}
|
||||
className={cn(
|
||||
"shrink-0 p-2 rounded-full transition-all",
|
||||
canExport && !isExporting
|
||||
? "text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm"
|
||||
: "text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{exportMenuOpen && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => { setExportMenuOpen(false); exportPPTX(); }}
|
||||
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
|
||||
>
|
||||
<FileDown className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<span>{t('export.pptx')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setExportMenuOpen(false); exportResourcePack(); }}
|
||||
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
|
||||
>
|
||||
<Package className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<div>{t('export.resourcePack')}</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">{t('export.resourcePackDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Volume2,
|
||||
Mic,
|
||||
SlidersHorizontal,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/use-i18n";
|
||||
import { useSettingsStore } from "@/lib/store/settings";
|
||||
import { IMAGE_PROVIDERS } from "@/lib/media/image-providers";
|
||||
import { VIDEO_PROVIDERS } from "@/lib/media/video-providers";
|
||||
import { TTS_PROVIDERS, getTTSVoices } from "@/lib/audio/constants";
|
||||
import { ASR_PROVIDERS, getASRSupportedLanguages } from "@/lib/audio/constants";
|
||||
import type { ImageProviderId, VideoProviderId } from "@/lib/media/types";
|
||||
import type { TTSProviderId, ASRProviderId } from "@/lib/audio/types";
|
||||
import type { SettingsSection } from "@/lib/types/settings";
|
||||
|
||||
interface MediaPopoverProps {
|
||||
onSettingsOpen: (section: SettingsSection) => void;
|
||||
}
|
||||
|
||||
// ─── Provider icon maps (IMAGE/VIDEO don't have built-in icons) ───
|
||||
const IMAGE_PROVIDER_ICONS: Record<string, string> = {
|
||||
seedream: "/logos/doubao.svg",
|
||||
"qwen-image": "/logos/bailian.svg",
|
||||
"nano-banana": "/logos/gemini.svg",
|
||||
};
|
||||
const VIDEO_PROVIDER_ICONS: Record<string, string> = {
|
||||
seedance: "/logos/doubao.svg",
|
||||
kling: "/logos/kling.svg",
|
||||
veo: "/logos/gemini.svg",
|
||||
sora: "/logos/openai.svg",
|
||||
};
|
||||
|
||||
// ─── Color themes per capability ───
|
||||
const STYLES = {
|
||||
image: {
|
||||
icon: ImageIcon,
|
||||
accent: "border-l-blue-500 dark:border-l-blue-400",
|
||||
enabledBg: "bg-blue-50/60 dark:bg-blue-950/30",
|
||||
iconBg: "bg-blue-500/10 dark:bg-blue-400/15",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
chipBg: "bg-blue-100 dark:bg-blue-900/40",
|
||||
chipText: "text-blue-700 dark:text-blue-300",
|
||||
chipRing: "ring-1 ring-blue-300 dark:ring-blue-600/60",
|
||||
},
|
||||
video: {
|
||||
icon: Video,
|
||||
accent: "border-l-purple-500 dark:border-l-purple-400",
|
||||
enabledBg: "bg-purple-50/60 dark:bg-purple-950/30",
|
||||
iconBg: "bg-purple-500/10 dark:bg-purple-400/15",
|
||||
iconColor: "text-purple-600 dark:text-purple-400",
|
||||
chipBg: "bg-purple-100 dark:bg-purple-900/40",
|
||||
chipText: "text-purple-700 dark:text-purple-300",
|
||||
chipRing: "ring-1 ring-purple-300 dark:ring-purple-600/60",
|
||||
},
|
||||
tts: {
|
||||
icon: Volume2,
|
||||
accent: "border-l-emerald-500 dark:border-l-emerald-400",
|
||||
enabledBg: "bg-emerald-50/60 dark:bg-emerald-950/30",
|
||||
iconBg: "bg-emerald-500/10 dark:bg-emerald-400/15",
|
||||
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||
chipBg: "bg-emerald-100 dark:bg-emerald-900/40",
|
||||
chipText: "text-emerald-700 dark:text-emerald-300",
|
||||
chipRing: "ring-1 ring-emerald-300 dark:ring-emerald-600/60",
|
||||
},
|
||||
asr: {
|
||||
icon: Mic,
|
||||
accent: "border-l-amber-500 dark:border-l-amber-400",
|
||||
enabledBg: "bg-amber-50/60 dark:bg-amber-950/30",
|
||||
iconBg: "bg-amber-500/10 dark:bg-amber-400/15",
|
||||
iconColor: "text-amber-600 dark:text-amber-400",
|
||||
chipBg: "bg-amber-100 dark:bg-amber-900/40",
|
||||
chipText: "text-amber-700 dark:text-amber-300",
|
||||
chipRing: "ring-1 ring-amber-300 dark:ring-amber-600/60",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// ─── Store ───
|
||||
const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled);
|
||||
const videoGenerationEnabled = useSettingsStore((s) => s.videoGenerationEnabled);
|
||||
const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
|
||||
const asrEnabled = useSettingsStore((s) => s.asrEnabled);
|
||||
const setImageGenerationEnabled = useSettingsStore((s) => s.setImageGenerationEnabled);
|
||||
const setVideoGenerationEnabled = useSettingsStore((s) => s.setVideoGenerationEnabled);
|
||||
const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
|
||||
const setASREnabled = useSettingsStore((s) => s.setASREnabled);
|
||||
|
||||
const imageProviderId = useSettingsStore((s) => s.imageProviderId);
|
||||
const imageModelId = useSettingsStore((s) => s.imageModelId);
|
||||
const imageProvidersConfig = useSettingsStore((s) => s.imageProvidersConfig);
|
||||
const setImageProvider = useSettingsStore((s) => s.setImageProvider);
|
||||
const setImageModelId = useSettingsStore((s) => s.setImageModelId);
|
||||
|
||||
const videoProviderId = useSettingsStore((s) => s.videoProviderId);
|
||||
const videoModelId = useSettingsStore((s) => s.videoModelId);
|
||||
const videoProvidersConfig = useSettingsStore((s) => s.videoProvidersConfig);
|
||||
const setVideoProvider = useSettingsStore((s) => s.setVideoProvider);
|
||||
const setVideoModelId = useSettingsStore((s) => s.setVideoModelId);
|
||||
|
||||
const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
|
||||
const ttsVoice = useSettingsStore((s) => s.ttsVoice);
|
||||
const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
|
||||
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
|
||||
const setTTSProvider = useSettingsStore((s) => s.setTTSProvider);
|
||||
const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
|
||||
const setTTSSpeed = useSettingsStore((s) => s.setTTSSpeed);
|
||||
|
||||
const asrProviderId = useSettingsStore((s) => s.asrProviderId);
|
||||
const asrLanguage = useSettingsStore((s) => s.asrLanguage);
|
||||
const asrProvidersConfig = useSettingsStore((s) => s.asrProvidersConfig);
|
||||
const setASRProvider = useSettingsStore((s) => s.setASRProvider);
|
||||
const setASRLanguage = useSettingsStore((s) => s.setASRLanguage);
|
||||
|
||||
const enabledCount = [imageGenerationEnabled, videoGenerationEnabled, ttsEnabled, asrEnabled].filter(Boolean).length;
|
||||
|
||||
const cfgOk = (
|
||||
configs: Record<string, { apiKey?: string; isServerConfigured?: boolean }>,
|
||||
id: string,
|
||||
needsKey: boolean
|
||||
) => !needsKey || !!configs[id]?.apiKey || !!configs[id]?.isServerConfigured;
|
||||
|
||||
const handleImageProviderChange = (pid: ImageProviderId) => {
|
||||
setImageProvider(pid);
|
||||
const p = IMAGE_PROVIDERS[pid];
|
||||
if (p?.models?.length) setImageModelId(p.models[0].id);
|
||||
};
|
||||
const handleVideoProviderChange = (pid: VideoProviderId) => {
|
||||
setVideoProvider(pid);
|
||||
const p = VIDEO_PROVIDERS[pid];
|
||||
if (p?.models?.length) setVideoModelId(p.models[0].id);
|
||||
};
|
||||
const handleTTSProviderChange = (pid: TTSProviderId) => {
|
||||
setTTSProvider(pid);
|
||||
const voices = getTTSVoices(pid);
|
||||
if (voices.length) setTTSVoice(voices[0].id);
|
||||
};
|
||||
const handleASRProviderChange = (pid: ASRProviderId) => {
|
||||
setASRProvider(pid);
|
||||
const langs = getASRSupportedLanguages(pid);
|
||||
if (langs.length) setASRLanguage(langs[0]);
|
||||
};
|
||||
|
||||
const ttsSpeedRange = TTS_PROVIDERS[ttsProviderId]?.speedRange;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border",
|
||||
enabledCount > 0
|
||||
? "bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border-violet-300 dark:border-violet-600 shadow-sm"
|
||||
: "text-muted-foreground/70 hover:text-foreground hover:bg-muted/60 border-border"
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{imageGenerationEnabled && <ImageIcon className="size-3 opacity-70" />}
|
||||
{videoGenerationEnabled && <Video className="size-3 opacity-70" />}
|
||||
{ttsEnabled && <Volume2 className="size-3 opacity-70" />}
|
||||
{asrEnabled && <Mic className="size-3 opacity-70" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" side="bottom" avoidCollisions={false} className="w-[400px] max-h-[var(--radix-popover-content-available-height)] overflow-y-auto p-2.5">
|
||||
<div className="space-y-1.5">
|
||||
{/* ── Image ── */}
|
||||
<Card
|
||||
theme={STYLES.image}
|
||||
label={t("media.imageCapability")}
|
||||
hint={t("media.imageHint")}
|
||||
enabled={imageGenerationEnabled}
|
||||
onToggle={setImageGenerationEnabled}
|
||||
>
|
||||
<ProviderChips
|
||||
providers={Object.values(IMAGE_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: IMAGE_PROVIDER_ICONS[p.id],
|
||||
available: cfgOk(imageProvidersConfig, p.id, p.requiresApiKey),
|
||||
}))}
|
||||
selectedId={imageProviderId}
|
||||
onSelect={(id) => handleImageProviderChange(id as ImageProviderId)}
|
||||
theme={STYLES.image}
|
||||
/>
|
||||
<CompactSelect
|
||||
value={imageModelId}
|
||||
onValueChange={setImageModelId}
|
||||
options={[
|
||||
...(IMAGE_PROVIDERS[imageProviderId]?.models || []),
|
||||
...(imageProvidersConfig[imageProviderId]?.customModels || []),
|
||||
].map((m) => ({
|
||||
value: m.id, label: m.name,
|
||||
}))}
|
||||
icon={IMAGE_PROVIDER_ICONS[imageProviderId]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Video ── */}
|
||||
<Card
|
||||
theme={STYLES.video}
|
||||
label={t("media.videoCapability")}
|
||||
hint={t("media.videoHint")}
|
||||
enabled={videoGenerationEnabled}
|
||||
onToggle={setVideoGenerationEnabled}
|
||||
>
|
||||
<ProviderChips
|
||||
providers={Object.values(VIDEO_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: VIDEO_PROVIDER_ICONS[p.id],
|
||||
available: cfgOk(videoProvidersConfig, p.id, p.requiresApiKey),
|
||||
}))}
|
||||
selectedId={videoProviderId}
|
||||
onSelect={(id) => handleVideoProviderChange(id as VideoProviderId)}
|
||||
theme={STYLES.video}
|
||||
/>
|
||||
<CompactSelect
|
||||
value={videoModelId}
|
||||
onValueChange={setVideoModelId}
|
||||
options={[
|
||||
...(VIDEO_PROVIDERS[videoProviderId]?.models || []),
|
||||
...(videoProvidersConfig[videoProviderId]?.customModels || []),
|
||||
].map((m) => ({
|
||||
value: m.id, label: m.name,
|
||||
}))}
|
||||
icon={VIDEO_PROVIDER_ICONS[videoProviderId]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── TTS ── */}
|
||||
<Card
|
||||
theme={STYLES.tts}
|
||||
label={t("media.ttsCapability")}
|
||||
hint={t("media.ttsHint")}
|
||||
enabled={ttsEnabled}
|
||||
onToggle={setTTSEnabled}
|
||||
>
|
||||
<ProviderChips
|
||||
providers={Object.values(TTS_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.icon,
|
||||
available: cfgOk(ttsProvidersConfig, p.id, p.requiresApiKey),
|
||||
}))}
|
||||
selectedId={ttsProviderId}
|
||||
onSelect={(id) => handleTTSProviderChange(id as TTSProviderId)}
|
||||
theme={STYLES.tts}
|
||||
/>
|
||||
<CompactSelect
|
||||
label={t("media.voice")}
|
||||
value={ttsVoice}
|
||||
onValueChange={setTTSVoice}
|
||||
options={getTTSVoices(ttsProviderId).map((v) => ({
|
||||
value: v.id, label: v.name,
|
||||
}))}
|
||||
icon={TTS_PROVIDERS[ttsProviderId]?.icon}
|
||||
/>
|
||||
{ttsSpeedRange && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[10px] text-muted-foreground/60 shrink-0">
|
||||
{t("media.speed")}
|
||||
</span>
|
||||
<Slider
|
||||
value={[ttsSpeed]}
|
||||
onValueChange={(value) => setTTSSpeed(value[0])}
|
||||
min={ttsSpeedRange.min}
|
||||
max={ttsSpeedRange.max}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right">
|
||||
{ttsSpeed.toFixed(1)}x
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── ASR ── */}
|
||||
<Card
|
||||
theme={STYLES.asr}
|
||||
label={t("media.asrCapability")}
|
||||
hint={t("media.asrHint")}
|
||||
enabled={asrEnabled}
|
||||
onToggle={setASREnabled}
|
||||
>
|
||||
<ProviderChips
|
||||
providers={Object.values(ASR_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.icon,
|
||||
available: cfgOk(asrProvidersConfig, p.id, p.requiresApiKey),
|
||||
}))}
|
||||
selectedId={asrProviderId}
|
||||
onSelect={(id) => handleASRProviderChange(id as ASRProviderId)}
|
||||
theme={STYLES.asr}
|
||||
/>
|
||||
<CompactSelect
|
||||
label={t("media.language")}
|
||||
value={asrLanguage}
|
||||
onValueChange={setASRLanguage}
|
||||
options={getASRSupportedLanguages(asrProviderId).map((l) => ({
|
||||
value: l, label: l,
|
||||
}))}
|
||||
icon={ASR_PROVIDERS[asrProviderId]?.icon}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced Settings ── */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
const section = imageGenerationEnabled ? "image"
|
||||
: videoGenerationEnabled ? "video"
|
||||
: ttsEnabled ? "tts" : "asr";
|
||||
onSettingsOpen(section);
|
||||
}}
|
||||
className="w-full flex items-center justify-between text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors mt-2 pt-2 border-t border-border/30"
|
||||
>
|
||||
<span>{t("toolbar.advancedSettings")}</span>
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: left-accent container per capability ───
|
||||
function Card({
|
||||
theme,
|
||||
label,
|
||||
hint,
|
||||
enabled,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
theme: (typeof STYLES)[keyof typeof STYLES];
|
||||
label: string;
|
||||
hint?: string;
|
||||
enabled: boolean;
|
||||
onToggle: (v: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const Icon = theme.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-[3px] transition-all",
|
||||
enabled
|
||||
? `${theme.accent} ${theme.enabledBg}`
|
||||
: "border-l-transparent"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
enabled ? theme.iconBg : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-colors",
|
||||
enabled ? theme.iconColor : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-[13px] font-medium transition-colors",
|
||||
!enabled && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground/60">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} className="scale-[0.85] origin-right" />
|
||||
</div>
|
||||
|
||||
{/* Content — provider chips + model/voice selects */}
|
||||
{enabled && (
|
||||
<div className="px-3 pb-2.5 space-y-1.5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ProviderChips: horizontal logo chips for provider selection ───
|
||||
function ProviderChips({
|
||||
providers,
|
||||
selectedId,
|
||||
onSelect,
|
||||
theme,
|
||||
}: {
|
||||
providers: Array<{ id: string; name: string; icon?: string; available: boolean }>;
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
theme: (typeof STYLES)[keyof typeof STYLES];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{providers.map((p) => {
|
||||
const selected = p.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => p.available && onSelect(p.id)}
|
||||
disabled={!p.available}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-[3px] text-[11px] font-medium transition-all",
|
||||
selected
|
||||
? `${theme.chipBg} ${theme.chipText} ${theme.chipRing} shadow-sm`
|
||||
: p.available
|
||||
? "bg-muted/60 text-foreground/80 hover:bg-muted ring-1 ring-border/50 hover:ring-border"
|
||||
: "bg-muted/30 text-muted-foreground/40 cursor-not-allowed ring-1 ring-border/20"
|
||||
)}
|
||||
>
|
||||
{p.icon && (
|
||||
<img
|
||||
src={p.icon}
|
||||
alt=""
|
||||
className={cn("size-3.5 rounded-sm", !p.available && "opacity-40")}
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{p.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CompactSelect: logo-embedded model/voice/language selector ───
|
||||
function CompactSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={label ? "space-y-1" : undefined}>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground font-medium pl-0.5">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 w-full rounded-lg border border-border/50 bg-muted/50 hover:bg-muted/70 shadow-none text-[11px] focus:ring-1 focus:ring-ring/30",
|
||||
icon ? "pl-2 pr-2.5" : "px-2.5"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{icon && <img src={icon} alt="" className="size-3.5 rounded-sm shrink-0" />}
|
||||
<span className="truncate">
|
||||
<SelectValue />
|
||||
</span>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { Play, Pause, X } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import type { DiscussionAction } from '@/lib/types/action';
|
||||
|
||||
interface ProactiveCardProps {
|
||||
action: DiscussionAction;
|
||||
mode: 'playback' | 'paused' | 'autonomous';
|
||||
/** Ref to the anchor element the card points to (avatar, etc.) */
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Where the card prefers to align relative to the anchor */
|
||||
align?: 'left' | 'right';
|
||||
agentName?: string;
|
||||
agentAvatar?: string;
|
||||
agentColor?: string;
|
||||
onSkip: () => void;
|
||||
onListen: () => void;
|
||||
onTogglePause: () => void;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 256; // w-64
|
||||
const VIEWPORT_PAD = 12;
|
||||
|
||||
/**
|
||||
* 主动讨论卡片组件
|
||||
*
|
||||
* 通过 React Portal 渲染到 document.body,使用 fixed 定位,
|
||||
* 不受父级 overflow/z-index stacking context 影响。
|
||||
*/
|
||||
export const ProactiveCard = ({ action, mode, anchorRef, align = 'right', agentName, agentAvatar, agentColor, onSkip, onListen, onTogglePause }: ProactiveCardProps) => {
|
||||
const { t } = useI18n();
|
||||
const [progress, setProgress] = useState(100);
|
||||
const skippedRef = useRef(false);
|
||||
const isPaused = mode === 'paused';
|
||||
|
||||
// Computed position state
|
||||
const [pos, setPos] = useState<{ left: number; bottom: number; tailOffset: number } | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const anchorCenterX = rect.left + rect.width / 2;
|
||||
const anchorTop = rect.top;
|
||||
|
||||
// Center card on anchor, clamped to viewport
|
||||
let cardLeft = anchorCenterX - CARD_WIDTH / 2;
|
||||
cardLeft = Math.max(VIEWPORT_PAD, Math.min(window.innerWidth - CARD_WIDTH - VIEWPORT_PAD, cardLeft));
|
||||
const tailOffset = Math.max(16, Math.min(CARD_WIDTH - 16, anchorCenterX - cardLeft));
|
||||
const bottom = window.innerHeight - anchorTop + 12; // 12px gap above anchor
|
||||
|
||||
setPos({ left: cardLeft, bottom, tailOffset });
|
||||
}, [anchorRef]);
|
||||
|
||||
// Continuously track anchor position via rAF to handle CSS transitions, sidebar collapse, etc.
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
updatePosition();
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'playback') return;
|
||||
|
||||
const duration = 5000;
|
||||
const interval = 50;
|
||||
const step = (interval / duration) * 100;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
const newProgress = prev - step;
|
||||
if (newProgress <= 0) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return newProgress;
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress <= 0 && !skippedRef.current && mode === 'playback') {
|
||||
skippedRef.current = true;
|
||||
onSkip();
|
||||
}
|
||||
}, [progress, onSkip, mode]);
|
||||
|
||||
if (!pos) return null;
|
||||
|
||||
const card = (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
||||
className="fixed w-64 z-[9999] pointer-events-auto"
|
||||
style={{
|
||||
left: pos.left,
|
||||
bottom: pos.bottom,
|
||||
...(align === 'left' ? { transformOrigin: 'bottom left' } : { transformOrigin: 'bottom right' }),
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSkip(); }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-white dark:bg-gray-800 shadow-md border border-gray-100 dark:border-gray-700 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:scale-110 transition-all z-20 group/close"
|
||||
title={t('proactiveCard.skip')}
|
||||
>
|
||||
<X className="w-3 h-3 stroke-[2.5]" />
|
||||
</button>
|
||||
|
||||
{/* Triangle Tail */}
|
||||
<div
|
||||
className="absolute -bottom-[6px] w-3 h-3 bg-white dark:bg-gray-800 border-b border-r border-gray-100 dark:border-gray-700 z-10"
|
||||
style={{
|
||||
left: `${pos.tailOffset}px`,
|
||||
transform: 'translateX(-50%) rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-3.5 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-gray-100 dark:border-gray-700 flex flex-col gap-2.5 relative overflow-hidden">
|
||||
{/* Progress Bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-50/50 dark:bg-gray-700/50">
|
||||
<div
|
||||
className={`h-full transition-all duration-[50ms] ease-linear ${
|
||||
isPaused
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: 'bg-gradient-to-r from-amber-400 to-amber-500 dark:from-amber-500 dark:to-amber-600'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-0.5 pt-1">
|
||||
{agentAvatar && (
|
||||
<div className="w-6 h-6 rounded-full overflow-hidden shrink-0 border border-gray-100 dark:border-gray-700">
|
||||
<img src={agentAvatar} alt={agentName || ''} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
{agentName && (
|
||||
<span className="text-[11px] font-semibold text-gray-700 dark:text-gray-300 truncate">
|
||||
{agentName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[9px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded-full shrink-0"
|
||||
style={{
|
||||
color: agentColor || '#d97706',
|
||||
backgroundColor: agentColor ? `${agentColor}18` : 'rgba(217, 119, 6, 0.08)',
|
||||
}}
|
||||
>
|
||||
{t('proactiveCard.discussion')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold tabular-nums shrink-0 ${
|
||||
isPaused ? 'text-gray-300 dark:text-gray-600' : 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{Math.max(0, Math.ceil((progress / 100) * 5))}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] font-bold text-gray-800 dark:text-gray-200 leading-snug px-0.5">
|
||||
{action.topic}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onListen(); }}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-amber-400 to-amber-500 hover:from-amber-500 hover:to-amber-600 dark:from-amber-500 dark:to-amber-600 dark:hover:from-amber-600 dark:hover:to-amber-700 text-white text-[11px] font-black rounded-lg flex items-center justify-center gap-1.5 transition-all active:scale-[0.97] shadow-sm shadow-amber-200/50 dark:shadow-amber-800/50"
|
||||
>
|
||||
<Play className="w-3 h-3 fill-current" /> {t('proactiveCard.join')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onTogglePause(); }}
|
||||
className={`p-2 aspect-square rounded-lg border transition-colors active:scale-90 ${
|
||||
isPaused
|
||||
? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/50'
|
||||
: 'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 border-gray-100 dark:border-gray-600'
|
||||
}`}
|
||||
title={isPaused ? t('proactiveCard.resume') : t('proactiveCard.pause')}
|
||||
>
|
||||
{isPaused ? <Play className="w-3 h-3 fill-current" /> : <Pause className="w-3 h-3 fill-current" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return createPortal(card, document.body);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { InteractiveContent } from '@/lib/types/stage';
|
||||
|
||||
interface InteractiveRendererProps {
|
||||
readonly content: InteractiveContent;
|
||||
readonly mode: 'autonomous' | 'playback';
|
||||
readonly sceneId: string;
|
||||
}
|
||||
|
||||
export function InteractiveRenderer({
|
||||
content,
|
||||
mode: _mode,
|
||||
sceneId,
|
||||
}: InteractiveRendererProps) {
|
||||
const patchedHtml = useMemo(
|
||||
() => (content.html ? patchHtmlForIframe(content.html) : undefined),
|
||||
[content.html],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<iframe
|
||||
srcDoc={patchedHtml}
|
||||
src={patchedHtml ? undefined : content.url}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
title={`Interactive Scene ${sceneId}`}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch embedded HTML to display correctly inside an iframe.
|
||||
*
|
||||
* Fixes:
|
||||
* - min-h-screen / h-screen → use 100% of iframe viewport
|
||||
* - Ensure html/body fill the iframe with no overflow issues
|
||||
* - Canvas elements use container sizing instead of viewport
|
||||
*/
|
||||
function patchHtmlForIframe(html: string): string {
|
||||
const iframeCss = `<style data-iframe-patch>
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Fix min-h-screen: in iframes 100vh is the iframe height, which is correct,
|
||||
but ensure body actually fills it */
|
||||
body { min-height: 100vh; }
|
||||
</style>`;
|
||||
|
||||
// Insert right after <head> or at the start of the document
|
||||
const headIdx = html.indexOf('<head>');
|
||||
if (headIdx !== -1) {
|
||||
const insertPos = headIdx + 6; // after <head>
|
||||
return html.substring(0, insertPos) + '\n' + iframeCss + html.substring(insertPos);
|
||||
}
|
||||
|
||||
const headWithAttrs = html.indexOf('<head ');
|
||||
if (headWithAttrs !== -1) {
|
||||
const closeAngle = html.indexOf('>', headWithAttrs);
|
||||
if (closeAngle !== -1) {
|
||||
const insertPos = closeAngle + 1;
|
||||
return html.substring(0, insertPos) + '\n' + iframeCss + html.substring(insertPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prepend
|
||||
return iframeCss + html;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import type { PBLContent } from '@/lib/types/stage'
|
||||
import type { PBLProjectConfig } from '@/lib/pbl/types'
|
||||
import { useStageStore } from '@/lib/store/stage'
|
||||
import { PBLRoleSelection } from './pbl/role-selection'
|
||||
import { PBLWorkspace } from './pbl/workspace'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
|
||||
interface PBLRendererProps {
|
||||
readonly content: PBLContent
|
||||
readonly mode: 'autonomous' | 'playback'
|
||||
readonly sceneId: string
|
||||
}
|
||||
|
||||
export function PBLRenderer({ content, mode: _mode, sceneId }: PBLRendererProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const { projectConfig } = content
|
||||
const selectedRole = projectConfig?.selectedRole ?? null
|
||||
|
||||
const updateConfig = useCallback((updatedConfig: PBLProjectConfig) => {
|
||||
const scenes = useStageStore.getState().scenes
|
||||
const updatedScenes = scenes.map(scene =>
|
||||
scene.id === sceneId
|
||||
? { ...scene, content: { type: 'pbl' as const, projectConfig: updatedConfig } }
|
||||
: scene
|
||||
)
|
||||
useStageStore.setState({ scenes: updatedScenes })
|
||||
}, [sceneId])
|
||||
|
||||
const handleSelectRole = useCallback((roleName: string) => {
|
||||
if (!projectConfig) return
|
||||
const newConfig = { ...projectConfig, selectedRole: roleName }
|
||||
|
||||
// Add Question Agent welcome message if chat is empty and active issue has questions
|
||||
const activeIssue = newConfig.issueboard.issues.find(i => i.is_active)
|
||||
if (activeIssue?.generated_questions && newConfig.chat.messages.length === 0) {
|
||||
const welcomeMsg = t('pbl.chat.welcomeMessage')
|
||||
.replace('{title}', activeIssue.title)
|
||||
.replace('{questions}', activeIssue.generated_questions)
|
||||
newConfig.chat = {
|
||||
messages: [{
|
||||
id: `msg_welcome_${Date.now()}`,
|
||||
agent_name: activeIssue.question_agent_name,
|
||||
message: welcomeMsg,
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
updateConfig(newConfig)
|
||||
}, [projectConfig, updateConfig, t])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!projectConfig) return
|
||||
// Reset all issues and re-activate the first one
|
||||
const resetIssues = projectConfig.issueboard.issues
|
||||
.map(i => ({ ...i, is_done: false, is_active: false }))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
if (resetIssues.length > 0) {
|
||||
resetIssues[0].is_active = true
|
||||
}
|
||||
|
||||
updateConfig({
|
||||
...projectConfig,
|
||||
selectedRole: null,
|
||||
chat: { messages: [] },
|
||||
issueboard: {
|
||||
...projectConfig.issueboard,
|
||||
issues: resetIssues,
|
||||
current_issue_id: resetIssues.length > 0 ? resetIssues[0].id : null,
|
||||
},
|
||||
})
|
||||
}, [projectConfig, updateConfig])
|
||||
|
||||
// Check for legacy format (old PBL with url/html)
|
||||
if (!projectConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p>{t('pbl.legacyFormat')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if project has been generated (has agents)
|
||||
if (projectConfig.agents.length === 0 && projectConfig.projectInfo.title === '') {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p>{t('pbl.emptyProject')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No role selected → show role selection
|
||||
if (!selectedRole) {
|
||||
return (
|
||||
<PBLRoleSelection
|
||||
projectInfo={projectConfig.projectInfo}
|
||||
agents={projectConfig.agents}
|
||||
onSelectRole={handleSelectRole}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Role selected → show workspace
|
||||
return (
|
||||
<PBLWorkspace
|
||||
projectConfig={projectConfig}
|
||||
userRole={selectedRole}
|
||||
onConfigUpdate={updateConfig}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import type { PBLChatMessage, PBLIssue } from '@/lib/pbl/types'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { useDraftCache } from '@/lib/hooks/use-draft-cache'
|
||||
import { SpeechButton } from '@/components/speech-button'
|
||||
|
||||
interface ChatPanelProps {
|
||||
readonly messages: PBLChatMessage[]
|
||||
readonly currentIssue: PBLIssue | null
|
||||
readonly userRole: string
|
||||
readonly isLoading: boolean
|
||||
readonly onSendMessage: (text: string) => void
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, currentIssue, userRole, isLoading, onSendMessage }: ChatPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const [input, setInput] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const composingRef = useRef(false)
|
||||
|
||||
// Draft cache
|
||||
const { cachedValue: cachedDraft, updateCache: updateDraftCache, clearCache: clearDraftCache } = useDraftCache<string>({ key: 'pblChatDraft' })
|
||||
|
||||
// Restore draft: use lazy initializer for first render, then sync via derived state
|
||||
const [prevCachedDraft, setPrevCachedDraft] = useState(cachedDraft)
|
||||
if (cachedDraft !== prevCachedDraft) {
|
||||
setPrevCachedDraft(cachedDraft)
|
||||
if (cachedDraft) {
|
||||
setInput(cachedDraft)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length])
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value)
|
||||
updateDraftCache(value)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
onSendMessage(input.trim())
|
||||
setInput('')
|
||||
clearDraftCache()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<h2 className="font-semibold text-sm">{t('pbl.chat.title')}</h2>
|
||||
{currentIssue && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('pbl.chat.currentIssue')}: {currentIssue.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} isUser={msg.agent_name === userRole} />
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<div className="flex gap-1">
|
||||
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
|
||||
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
|
||||
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-3">
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground mb-2">
|
||||
<span>{t('pbl.chat.mentionHint')}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
placeholder={t('pbl.chat.placeholder')}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
|
||||
/>
|
||||
<SpeechButton
|
||||
size="md"
|
||||
disabled={isLoading}
|
||||
onTranscription={(text) => {
|
||||
setInput((prev) => {
|
||||
const next = prev + (prev ? ' ' : '') + text;
|
||||
updateDraftCache(next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center transition-colors bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatMessage({ message, isUser }: { message: PBLChatMessage; isUser: boolean }) {
|
||||
const isSystem = message.agent_name === 'System'
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<span className="text-xs text-muted-foreground bg-muted/50 rounded-full px-3 py-1">
|
||||
{message.message}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
|
||||
<span className="text-[10px] font-medium text-muted-foreground mb-0.5 px-1">
|
||||
{message.agent_name}
|
||||
</span>
|
||||
<div
|
||||
className={`rounded-xl px-3 py-2 text-sm max-w-[85%] ${
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground whitespace-pre-wrap'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{isUser
|
||||
? message.message
|
||||
: <MessageResponse>{message.message}</MessageResponse>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
|
||||
|
||||
/**
|
||||
* Inline guide shown below the role selection cards.
|
||||
* Hover to reveal the 3-step PBL workflow as a popover above.
|
||||
*/
|
||||
export function PBLGuideInline() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={0} closeDelay={150}>
|
||||
<div className="w-full flex justify-center">
|
||||
<HoverCardTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
<span>{t('pbl.guide.howItWorks')}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<HoverCardContent
|
||||
side="top"
|
||||
collisionPadding={16}
|
||||
className="w-[380px] overflow-y-auto rounded-xl p-5"
|
||||
style={{ maxHeight: 'var(--radix-hover-card-content-available-height, 70vh)' }}
|
||||
>
|
||||
<GuideContent />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Help button in workspace toolbar — hover to show guide popover.
|
||||
*/
|
||||
export function PBLGuidePanel() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={0} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t('pbl.guide.help')}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
collisionPadding={16}
|
||||
className="w-[380px] overflow-y-auto rounded-xl p-5"
|
||||
style={{ maxHeight: 'var(--radix-hover-card-content-available-height, 80vh)' }}
|
||||
>
|
||||
<GuideContent />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideContent() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="space-y-5 text-[13px] leading-relaxed text-foreground">
|
||||
{/* Step 1 */}
|
||||
<section>
|
||||
<h4 className="font-semibold mb-1">{t('pbl.guide.step1.title')}</h4>
|
||||
<p className="text-muted-foreground">{t('pbl.guide.step1.desc')}</p>
|
||||
</section>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Step 2 */}
|
||||
<section>
|
||||
<h4 className="font-semibold mb-1">{t('pbl.guide.step2.title')}</h4>
|
||||
<p className="text-muted-foreground mb-3">{t('pbl.guide.step2.desc')}</p>
|
||||
|
||||
<ol className="list-decimal list-inside space-y-3 text-muted-foreground">
|
||||
{/* 2-1 */}
|
||||
<li>
|
||||
<span className="font-medium text-foreground">{t('pbl.guide.step2.s1.title')}</span>
|
||||
<p className="mt-0.5 ml-[1.125rem]">{t('pbl.guide.step2.s1.desc')}</p>
|
||||
</li>
|
||||
|
||||
{/* 2-2 */}
|
||||
<li>
|
||||
<span className="font-medium text-foreground">{t('pbl.guide.step2.s2.title')}</span>
|
||||
<code className="ml-1.5 text-xs bg-muted rounded px-1.5 py-0.5 font-mono">@question</code>
|
||||
<div className="mt-1.5 ml-[1.125rem] space-y-1.5">
|
||||
<pre className="text-xs bg-muted/70 rounded-md px-3 py-2 font-mono leading-relaxed overflow-x-auto">{t('pbl.guide.step2.s2.example')}</pre>
|
||||
<p>{t('pbl.guide.step2.s2.desc')}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* 2-3 */}
|
||||
<li>
|
||||
<span className="font-medium text-foreground">{t('pbl.guide.step2.s3.title')}</span>
|
||||
<code className="ml-1.5 text-xs bg-muted rounded px-1.5 py-0.5 font-mono">@judge</code>
|
||||
<div className="mt-1.5 ml-[1.125rem] space-y-1.5">
|
||||
<pre className="text-xs bg-muted/70 rounded-md px-3 py-2 font-mono leading-relaxed overflow-x-auto">{t('pbl.guide.step2.s3.example')}</pre>
|
||||
<p>{t('pbl.guide.step2.s3.desc')}</p>
|
||||
<ul className="space-y-0.5 mt-1">
|
||||
<li>✅ <span className="font-medium text-foreground">COMPLETE</span> → {t('pbl.guide.step2.s3.complete')}</li>
|
||||
<li>🔄 <span className="font-medium text-foreground">NEEDS_REVISION</span> → {t('pbl.guide.step2.s3.revision')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Step 3 */}
|
||||
<section>
|
||||
<h4 className="font-semibold mb-1">{t('pbl.guide.step3.title')}</h4>
|
||||
<p className="text-muted-foreground">{t('pbl.guide.step3.desc')}</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { PBLIssueboard, PBLIssue } from '@/lib/pbl/types'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
|
||||
interface IssueboardPanelProps {
|
||||
readonly issueboard: PBLIssueboard
|
||||
}
|
||||
|
||||
export function IssueboardPanel({ issueboard }: IssueboardPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const sortedIssues = [...issueboard.issues].sort((a, b) => a.index - b.index)
|
||||
|
||||
const doneCount = sortedIssues.filter(i => i.is_done).length
|
||||
const totalCount = sortedIssues.length
|
||||
const progressPercent = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b">
|
||||
<h2 className="font-semibold text-sm">{t('pbl.issueboard.title')}</h2>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue List */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{sortedIssues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
{sortedIssues.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t('pbl.issueboard.noIssues')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IssueCard({ issue }: { issue: PBLIssue }) {
|
||||
const { t } = useI18n()
|
||||
const statusColor = issue.is_done
|
||||
? 'border-green-500/50 bg-green-50 dark:bg-green-950/20'
|
||||
: issue.is_active
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted'
|
||||
|
||||
const statusLabel = issue.is_done
|
||||
? t('pbl.issueboard.statusDone')
|
||||
: issue.is_active
|
||||
? t('pbl.issueboard.statusActive')
|
||||
: t('pbl.issueboard.statusPending')
|
||||
|
||||
const statusBadgeColor = issue.is_done
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: issue.is_active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-3 ${statusColor} transition-colors`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-medium text-sm leading-tight">{issue.title}</h3>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full whitespace-nowrap ${statusBadgeColor}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{issue.description}</p>
|
||||
<div className="text-[10px] text-muted-foreground mt-1.5">
|
||||
{issue.person_in_charge}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { PBLAgent, PBLProjectInfo } from '@/lib/pbl/types'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
import { PBLGuideInline } from './guide'
|
||||
|
||||
interface PBLRoleSelectionProps {
|
||||
readonly projectInfo: PBLProjectInfo
|
||||
readonly agents: PBLAgent[]
|
||||
readonly onSelectRole: (agentName: string) => void
|
||||
}
|
||||
|
||||
export function PBLRoleSelection({ projectInfo, agents, onSelectRole }: PBLRoleSelectionProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
// Only show non-system development roles
|
||||
const selectableAgents = agents.filter(a => !a.is_system_agent && a.role_division === 'development')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center h-full overflow-y-auto p-8 bg-gradient-to-b from-background to-muted/30">
|
||||
<div className="max-w-2xl w-full space-y-8 my-auto">
|
||||
{/* Project Info */}
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{projectInfo.title}</h1>
|
||||
<p className="text-muted-foreground text-lg">{projectInfo.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-center">{t('pbl.roleSelection.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground text-center">{t('pbl.roleSelection.description')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{selectableAgents.map((agent) => (
|
||||
<button
|
||||
key={agent.name}
|
||||
onClick={() => onSelectRole(agent.name)}
|
||||
className="group relative flex flex-col items-start gap-2 rounded-xl border-2 border-muted bg-card p-5 text-left transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-sm">
|
||||
{agent.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h3 className="font-semibold text-base">{agent.name}</h3>
|
||||
</div>
|
||||
{agent.actor_role && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{agent.actor_role}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works guide */}
|
||||
<PBLGuideInline />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* PBL Chat Hook - Manages chat state, @mention parsing, and API calls
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { PBLProjectConfig, PBLChatMessage, PBLAgent, PBLIssue } from '@/lib/pbl/types'
|
||||
import { getCurrentModelConfig } from '@/lib/utils/model-config'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('PBLChat')
|
||||
|
||||
interface UsePBLChatOptions {
|
||||
projectConfig: PBLProjectConfig
|
||||
userRole: string
|
||||
onConfigUpdate: (config: PBLProjectConfig) => void
|
||||
}
|
||||
|
||||
export function usePBLChat({ projectConfig, userRole, onConfigUpdate }: UsePBLChatOptions) {
|
||||
const { t } = useI18n()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const messages = projectConfig.chat.messages
|
||||
|
||||
const currentIssue = projectConfig.issueboard.issues.find(i => i.is_active) || null
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (!text.trim() || isLoading) return
|
||||
|
||||
const updatedConfig = { ...projectConfig, chat: { ...projectConfig.chat, messages: [...projectConfig.chat.messages] } }
|
||||
|
||||
// Add user message
|
||||
const userMsg: PBLChatMessage = {
|
||||
id: `msg_${Date.now()}_user`,
|
||||
agent_name: userRole,
|
||||
message: text,
|
||||
timestamp: Date.now(),
|
||||
read_by: [userRole],
|
||||
}
|
||||
updatedConfig.chat.messages.push(userMsg)
|
||||
onConfigUpdate(updatedConfig)
|
||||
|
||||
// Parse @mention to determine target agent, fallback to question agent
|
||||
const targetAgent = resolveTargetAgent(text, currentIssue, projectConfig.agents)
|
||||
if (!targetAgent) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const modelConfig = getCurrentModelConfig()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-model': modelConfig.modelString,
|
||||
'x-api-key': modelConfig.apiKey,
|
||||
}
|
||||
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl
|
||||
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType
|
||||
if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true'
|
||||
|
||||
// Strip @mention prefix from message text if present
|
||||
const cleanMessage = text.replace(/^@\w+\s*/i, '').trim() || text
|
||||
|
||||
const isJudgeAgent = currentIssue && targetAgent.name === currentIssue.judge_agent_name
|
||||
|
||||
const response = await fetch('/api/pbl/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: cleanMessage,
|
||||
agent: targetAgent,
|
||||
currentIssue,
|
||||
recentMessages: updatedConfig.chat.messages.slice(-10).map(m => ({
|
||||
agent_name: m.agent_name,
|
||||
message: m.message,
|
||||
})),
|
||||
userRole,
|
||||
agentType: isJudgeAgent ? 'judge' : 'question',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
const agentMsg: PBLChatMessage = {
|
||||
id: `msg_${Date.now()}_agent`,
|
||||
agent_name: targetAgent.name,
|
||||
message: data.message,
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
}
|
||||
|
||||
const afterConfig = {
|
||||
...updatedConfig,
|
||||
chat: { messages: [...updatedConfig.chat.messages, agentMsg] },
|
||||
}
|
||||
|
||||
// Check for COMPLETE from judge agent (excluding NEEDS_REVISION)
|
||||
const msgUpper = data.message.toUpperCase()
|
||||
if (
|
||||
currentIssue &&
|
||||
isJudgeAgent &&
|
||||
msgUpper.includes('COMPLETE') &&
|
||||
!msgUpper.includes('NEEDS_REVISION')
|
||||
) {
|
||||
await handleIssueComplete(afterConfig, currentIssue, headers, t)
|
||||
}
|
||||
|
||||
onConfigUpdate(afterConfig)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[usePBLChat] Error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [projectConfig, userRole, currentIssue, isLoading, onConfigUpdate, t])
|
||||
|
||||
return { messages, isLoading, sendMessage, currentIssue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve target agent from @mention, or fallback to question agent for plain messages
|
||||
*/
|
||||
function resolveTargetAgent(
|
||||
text: string,
|
||||
currentIssue: PBLIssue | null,
|
||||
agents: PBLAgent[],
|
||||
): PBLAgent | null {
|
||||
if (!currentIssue) return null
|
||||
|
||||
const mentionMatch = text.match(/^@(\w+)/i)
|
||||
if (mentionMatch) {
|
||||
const mentionType = mentionMatch[1].toLowerCase()
|
||||
|
||||
if (mentionType === 'question') {
|
||||
return agents.find(a => a.name === currentIssue.question_agent_name) || null
|
||||
}
|
||||
if (mentionType === 'judge') {
|
||||
return agents.find(a => a.name === currentIssue.judge_agent_name) || null
|
||||
}
|
||||
|
||||
// Direct agent name mention
|
||||
const matched = agents.find(a => a.name.toLowerCase().includes(mentionType))
|
||||
if (matched) return matched
|
||||
}
|
||||
|
||||
// No @mention or unrecognized mention → route to question agent by default
|
||||
return agents.find(a => a.name === currentIssue.question_agent_name) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle issue completion: mark done, activate next, generate questions for next issue
|
||||
*/
|
||||
async function handleIssueComplete(
|
||||
config: PBLProjectConfig,
|
||||
completedIssue: PBLIssue,
|
||||
headers: Record<string, string>,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
// Mark current issue as done
|
||||
const issue = config.issueboard.issues.find(i => i.id === completedIssue.id)
|
||||
if (issue) {
|
||||
issue.is_done = true
|
||||
issue.is_active = false
|
||||
}
|
||||
config.issueboard.current_issue_id = null
|
||||
|
||||
// Activate next incomplete issue
|
||||
const nextIssue = config.issueboard.issues
|
||||
.filter(i => !i.is_done)
|
||||
.sort((a, b) => a.index - b.index)[0]
|
||||
|
||||
if (nextIssue) {
|
||||
nextIssue.is_active = true
|
||||
config.issueboard.current_issue_id = nextIssue.id
|
||||
|
||||
// Generate questions for the new issue if not already generated
|
||||
const questionAgent = config.agents.find(a => a.name === nextIssue.question_agent_name)
|
||||
if (questionAgent && !nextIssue.generated_questions) {
|
||||
try {
|
||||
const questionPrompt = [
|
||||
`## Issue Information`,
|
||||
``,
|
||||
`**Title**: ${nextIssue.title}`,
|
||||
`**Description**: ${nextIssue.description}`,
|
||||
`**Person in Charge**: ${nextIssue.person_in_charge}`,
|
||||
nextIssue.participants.length > 0 ? `**Participants**: ${nextIssue.participants.join(', ')}` : '',
|
||||
nextIssue.notes ? `**Notes**: ${nextIssue.notes}` : '',
|
||||
``,
|
||||
`## Your Task`,
|
||||
``,
|
||||
`Based on the issue information above, generate 1-3 specific, actionable questions that will help students understand and complete this issue. Format your response as a numbered list.`,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
const resp = await fetch('/api/pbl/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: questionPrompt,
|
||||
agent: questionAgent,
|
||||
currentIssue: nextIssue,
|
||||
recentMessages: [],
|
||||
userRole: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await resp.json()
|
||||
if (data.success && data.message) {
|
||||
nextIssue.generated_questions = data.message
|
||||
|
||||
// Add Question Agent welcome message
|
||||
config.chat.messages.push({
|
||||
id: `msg_${Date.now()}_welcome`,
|
||||
agent_name: nextIssue.question_agent_name,
|
||||
message: t('pbl.chat.welcomeMessage')
|
||||
.replace('{title}', nextIssue.title)
|
||||
.replace('{questions}', data.message),
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[usePBLChat] Failed to generate questions for next issue:', error)
|
||||
}
|
||||
} else if (questionAgent && nextIssue.generated_questions) {
|
||||
// Questions already exist, just add welcome message
|
||||
config.chat.messages.push({
|
||||
id: `msg_${Date.now()}_welcome`,
|
||||
agent_name: nextIssue.question_agent_name,
|
||||
message: t('pbl.chat.welcomeMessage')
|
||||
.replace('{title}', nextIssue.title)
|
||||
.replace('{questions}', nextIssue.generated_questions),
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
})
|
||||
}
|
||||
|
||||
// System message about progression
|
||||
config.chat.messages.push({
|
||||
id: `msg_${Date.now()}_system`,
|
||||
agent_name: 'System',
|
||||
message: t('pbl.chat.issueCompleteMessage')
|
||||
.replace('{completed}', completedIssue.title)
|
||||
.replace('{next}', nextIssue.title),
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
})
|
||||
} else {
|
||||
// All issues complete
|
||||
config.chat.messages.push({
|
||||
id: `msg_${Date.now()}_system`,
|
||||
agent_name: 'System',
|
||||
message: t('pbl.chat.allCompleteMessage'),
|
||||
timestamp: Date.now(),
|
||||
read_by: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { PBLProjectConfig } from '@/lib/pbl/types'
|
||||
import { IssueboardPanel } from './issueboard-panel'
|
||||
import { ChatPanel } from './chat-panel'
|
||||
import { usePBLChat } from './use-pbl-chat'
|
||||
import { PBLGuidePanel } from './guide'
|
||||
import { useI18n } from '@/lib/use-i18n'
|
||||
|
||||
interface PBLWorkspaceProps {
|
||||
readonly projectConfig: PBLProjectConfig
|
||||
readonly userRole: string
|
||||
readonly onConfigUpdate: (config: PBLProjectConfig) => void
|
||||
readonly onReset: () => void
|
||||
}
|
||||
|
||||
export function PBLWorkspace({ projectConfig, userRole, onConfigUpdate, onReset }: PBLWorkspaceProps) {
|
||||
const { t } = useI18n()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const { messages, isLoading, sendMessage, currentIssue } = usePBLChat({
|
||||
projectConfig,
|
||||
userRole,
|
||||
onConfigUpdate,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Left: Issueboard (~35%) */}
|
||||
<div className="w-[35%] min-w-[280px] border-r overflow-hidden flex flex-col">
|
||||
{/* Back button bar */}
|
||||
<div className="px-3 pt-2 flex items-center gap-1.5">
|
||||
{!showConfirm ? (
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-1.5 py-1 rounded-md hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>{t('pbl.workspace.restart')}</span>
|
||||
</button>
|
||||
<div className="ml-auto">
|
||||
<PBLGuidePanel />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{t('pbl.workspace.confirmRestart')}</span>
|
||||
<button
|
||||
onClick={() => { setShowConfirm(false); onReset() }}
|
||||
className="px-2 py-0.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
{t('pbl.workspace.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="px-2 py-0.5 rounded bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{t('pbl.workspace.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<IssueboardPanel issueboard={projectConfig.issueboard} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Chat (~65%) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
currentIssue={currentIssue}
|
||||
userRole={userRole}
|
||||
isLoading={isLoading}
|
||||
onSendMessage={sendMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { QuizContent } from '@/lib/types/stage';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuizRendererProps {
|
||||
readonly content: QuizContent;
|
||||
readonly mode: 'autonomous' | 'playback';
|
||||
readonly sceneId: string;
|
||||
}
|
||||
|
||||
export function QuizRenderer({ content, mode, sceneId: _sceneId }: QuizRendererProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
|
||||
const handleAnswerChange = (questionId: string, answer: string) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: answer }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto p-8">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<h1 className="text-3xl font-bold">Quiz</h1>
|
||||
{content.questions.map((question) => (
|
||||
<Card key={question.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{question.question}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{question.type === 'single' && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, optIndex) => {
|
||||
// Normalize: options may be QuizOption objects or plain strings from AI
|
||||
const optionValue = typeof option === 'string' ? option : option.value;
|
||||
const optionLabel = typeof option === 'string' ? option : option.label;
|
||||
const letterPrefix = String.fromCharCode(65 + optIndex); // A, B, C, D...
|
||||
|
||||
return (
|
||||
<label
|
||||
key={`${question.id}-opt-${optIndex}`}
|
||||
className={cn(
|
||||
'flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-muted',
|
||||
answers[question.id] === (optionValue || letterPrefix) &&
|
||||
'bg-muted'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={question.id}
|
||||
value={optionValue || letterPrefix}
|
||||
checked={answers[question.id] === (optionValue || letterPrefix)}
|
||||
onChange={(e) =>
|
||||
handleAnswerChange(question.id, e.target.value)
|
||||
}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>{letterPrefix}. {optionLabel}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{question.type === 'short_answer' && (
|
||||
<textarea
|
||||
className="w-full min-h-24 p-2 border rounded"
|
||||
placeholder="Enter your answer..."
|
||||
value={answers[question.id] || ''}
|
||||
onChange={(e) =>
|
||||
handleAnswerChange(question.id, e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{mode === 'autonomous' && (
|
||||
<div className="flex justify-end">
|
||||
<Button>Submit Answers</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
PieChart,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
ChevronRight,
|
||||
Check,
|
||||
BookOpenText,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/use-i18n';
|
||||
import { getCurrentModelConfig } from '@/lib/utils/model-config';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('QuizView');
|
||||
import type { QuizQuestion } from '@/lib/types/stage';
|
||||
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
|
||||
import { SpeechButton } from '@/components/speech-button';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing';
|
||||
|
||||
interface QuestionResult {
|
||||
questionId: string;
|
||||
correct: boolean | null;
|
||||
status: 'correct' | 'incorrect';
|
||||
earned: number;
|
||||
aiComment?: string;
|
||||
}
|
||||
|
||||
interface QuizViewProps {
|
||||
readonly questions: QuizQuestion[];
|
||||
readonly sceneId: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sa = [...a].sort();
|
||||
const sb = [...b].sort();
|
||||
return sa.every((v, i) => v === sb[i]);
|
||||
}
|
||||
|
||||
function toArray(v: string | string[] | undefined): string[] {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
function isShortAnswer(q: QuizQuestion): boolean {
|
||||
return q.type === 'short_answer' || (!q.hasAnswer && (!q.answer || q.answer.length === 0));
|
||||
}
|
||||
|
||||
/** Grade choice questions locally. Returns results only for non-short-answer questions. */
|
||||
function gradeChoiceQuestions(
|
||||
questions: QuizQuestion[],
|
||||
answers: Record<string, string | string[]>,
|
||||
): QuestionResult[] {
|
||||
return questions
|
||||
.filter((q) => !isShortAnswer(q))
|
||||
.map((q) => {
|
||||
const pts = q.points ?? 1;
|
||||
const userAnswer = toArray(answers[q.id]);
|
||||
const correctAnswer = toArray(q.answer);
|
||||
const correct = arraysEqual(userAnswer, correctAnswer);
|
||||
return {
|
||||
questionId: q.id,
|
||||
correct,
|
||||
status: correct ? 'correct' as const : 'incorrect' as const,
|
||||
earned: correct ? pts : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Call /api/quiz-grade for a single short-answer question. */
|
||||
async function gradeShortAnswerQuestion(
|
||||
q: QuizQuestion,
|
||||
userAnswer: string,
|
||||
language: string,
|
||||
): Promise<QuestionResult> {
|
||||
const pts = q.points ?? 1;
|
||||
try {
|
||||
const modelConfig = getCurrentModelConfig();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-model': modelConfig.modelString,
|
||||
'x-api-key': modelConfig.apiKey,
|
||||
};
|
||||
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
|
||||
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
|
||||
if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true';
|
||||
|
||||
const res = await fetch('/api/quiz-grade', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
question: q.question,
|
||||
userAnswer,
|
||||
points: pts,
|
||||
commentPrompt: q.commentPrompt,
|
||||
language,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { score: number; comment: string };
|
||||
const earned = Math.max(0, Math.min(pts, data.score));
|
||||
return {
|
||||
questionId: q.id,
|
||||
correct: earned >= pts * 0.8,
|
||||
status: earned >= pts * 0.8 ? 'correct' : 'incorrect',
|
||||
earned,
|
||||
aiComment: data.comment,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error('[quiz-view] AI grading failed for', q.id, err);
|
||||
// Fallback: give half credit
|
||||
return {
|
||||
questionId: q.id,
|
||||
correct: null,
|
||||
status: 'incorrect',
|
||||
earned: Math.round(pts * 0.5),
|
||||
aiComment: language === 'zh-CN' ? '评分服务暂时不可用,已给予基础分。' : 'Grading service unavailable. Base score given.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ─────────────────────────────────────────────────────────
|
||||
|
||||
function QuizCover({
|
||||
questionCount,
|
||||
totalPoints,
|
||||
onStart,
|
||||
}: {
|
||||
questionCount: number;
|
||||
totalPoints: number;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 p-6 opacity-[0.03]">
|
||||
<PieChart className="w-52 h-52 text-violet-500" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 p-6 opacity-[0.02]">
|
||||
<BookOpenText className="w-40 h-40 text-violet-500 rotate-12" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
|
||||
className="w-16 h-16 bg-gradient-to-br from-violet-100 to-purple-50 dark:from-violet-900/50 dark:to-purple-900/30 rounded-2xl flex items-center justify-center shadow-lg shadow-violet-100 dark:shadow-violet-900/30 ring-1 ring-violet-200/50 dark:ring-violet-700/50"
|
||||
>
|
||||
<PieChart className="w-8 h-8 text-violet-500" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-center z-10"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100">{t('quiz.title')}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('quiz.subtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex gap-5 text-sm z-10"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<BookOpenText className="w-3.5 h-3.5 text-violet-500" />
|
||||
</div>
|
||||
<span>{questionCount} {t('quiz.questionsCount')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<PieChart className="w-3.5 h-3.5 text-violet-500" />
|
||||
</div>
|
||||
<span>{t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={onStart}
|
||||
className="mt-1 px-8 py-2.5 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full font-medium shadow-lg shadow-violet-200/50 dark:shadow-violet-900/50 hover:shadow-violet-300/50 transition-shadow z-10 flex items-center gap-2"
|
||||
>
|
||||
{t('quiz.startQuiz')}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleChoiceQuestion({
|
||||
question,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
result,
|
||||
}: {
|
||||
question: QuizQuestion;
|
||||
index: number;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
result?: QuestionResult;
|
||||
}) {
|
||||
const isReview = !!result;
|
||||
|
||||
return (
|
||||
<QuestionCard question={question} index={index} result={result}>
|
||||
<div className="grid gap-2">
|
||||
{question.options?.map((opt) => {
|
||||
const selected = value === opt.value;
|
||||
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
|
||||
const isWrong = isReview && selected && result?.status === 'incorrect';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(opt.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
|
||||
// Default state
|
||||
!isReview && !selected && 'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30',
|
||||
!isReview && selected && 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
|
||||
// Review states
|
||||
isReview && isCorrectOpt && 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
|
||||
isReview && isWrong && !isCorrectOpt && 'border-red-300 bg-red-50 dark:bg-red-900/30',
|
||||
isReview && !isCorrectOpt && !selected && 'border-gray-100 dark:border-gray-700 opacity-60',
|
||||
disabled && !isReview && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
|
||||
!isReview && !selected && 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
|
||||
!isReview && selected && 'bg-violet-500 text-white',
|
||||
isReview && isCorrectOpt && 'bg-emerald-500 text-white',
|
||||
isReview && isWrong && !isCorrectOpt && 'bg-red-400 text-white',
|
||||
isReview && !isCorrectOpt && !selected && 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
{opt.value}
|
||||
</span>
|
||||
<span className={cn('flex-1', isReview && !isCorrectOpt && !selected && 'text-gray-400 dark:text-gray-500')}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isReview && isCorrectOpt && <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />}
|
||||
{isReview && isWrong && !isCorrectOpt && <XCircle className="w-5 h-5 text-red-400 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleChoiceQuestion({
|
||||
question,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
result,
|
||||
}: {
|
||||
question: QuizQuestion;
|
||||
index: number;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
result?: QuestionResult;
|
||||
}) {
|
||||
const isReview = !!result;
|
||||
const selected = value ?? [];
|
||||
|
||||
const toggle = (optValue: string) => {
|
||||
if (disabled) return;
|
||||
if (selected.includes(optValue)) {
|
||||
onChange(selected.filter((v) => v !== optValue));
|
||||
} else {
|
||||
onChange([...selected, optValue]);
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<QuestionCard question={question} index={index} result={result}>
|
||||
{!isReview && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">{t('quiz.multipleChoiceHint')}</p>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
{question.options?.map((opt) => {
|
||||
const isSelected = selected.includes(opt.value);
|
||||
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
|
||||
const isWrong = isReview && isSelected && !isCorrectOpt;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(opt.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
|
||||
!isReview && !isSelected && 'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30',
|
||||
!isReview && isSelected && 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
|
||||
isReview && isCorrectOpt && 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
|
||||
isReview && isWrong && 'border-red-300 bg-red-50 dark:bg-red-900/30',
|
||||
isReview && !isCorrectOpt && !isSelected && 'border-gray-100 dark:border-gray-700 opacity-60',
|
||||
disabled && !isReview && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
|
||||
!isReview && !isSelected && 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
|
||||
!isReview && isSelected && 'bg-violet-500 text-white',
|
||||
isReview && isCorrectOpt && 'bg-emerald-500 text-white',
|
||||
isReview && isWrong && 'bg-red-400 text-white',
|
||||
isReview && !isCorrectOpt && !isSelected && 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
{!isReview && isSelected ? <Check className="w-3.5 h-3.5" /> : opt.value}
|
||||
</span>
|
||||
<span className={cn('flex-1', isReview && !isCorrectOpt && !isSelected && 'text-gray-400 dark:text-gray-500')}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isReview && isCorrectOpt && <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />}
|
||||
{isReview && isWrong && <XCircle className="w-5 h-5 text-red-400 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortAnswerQuestion({
|
||||
question,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
result,
|
||||
}: {
|
||||
question: QuizQuestion;
|
||||
index: number;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
result?: QuestionResult;
|
||||
}) {
|
||||
const isReview = !!result;
|
||||
const { t } = useI18n();
|
||||
// Ref to track latest value for voice transcription append
|
||||
const valueRef = useRef(value);
|
||||
useEffect(() => { valueRef.current = value; }, [value]);
|
||||
|
||||
return (
|
||||
<QuestionCard question={question} index={index} result={result}>
|
||||
{!isReview ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={t('quiz.inputPlaceholder')}
|
||||
className="w-full min-h-[100px] p-3 pb-10 rounded-xl border border-gray-200 dark:border-gray-600 text-sm resize-none focus:outline-none focus:border-violet-300 dark:focus:border-violet-600 focus:ring-2 focus:ring-violet-100 dark:focus:ring-violet-900/50 transition-all disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:text-gray-500 dark:bg-gray-800/50 dark:text-gray-200 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<SpeechButton
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="absolute bottom-3 left-3"
|
||||
onTranscription={(text) => {
|
||||
const cur = valueRef.current ?? '';
|
||||
onChange(cur + (cur ? ' ' : '') + text);
|
||||
}}
|
||||
/>
|
||||
<span className="absolute bottom-3 right-3 text-xs text-gray-300 dark:text-gray-600">
|
||||
{(value ?? '').length} {t('quiz.charCount')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{t('quiz.yourAnswer')}</p>
|
||||
{value || <span className="text-gray-400 dark:text-gray-500 italic">{t('quiz.notAnswered')}</span>}
|
||||
</div>
|
||||
{result.aiComment && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-violet-50 dark:bg-violet-900/30 border border-violet-100 dark:border-violet-800">
|
||||
<Sparkles className="w-4 h-4 text-violet-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-violet-600 dark:text-violet-400 mb-0.5">{t('quiz.aiComment')}</p>
|
||||
<p className="text-xs text-violet-600/80 dark:text-violet-400/80">{result.aiComment}</p>
|
||||
</div>
|
||||
<span className="ml-auto text-xs font-bold text-violet-600 dark:text-violet-400 shrink-0">
|
||||
{result.earned}/{question.points ?? 1}{t('quiz.pointsSuffix')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
question,
|
||||
index,
|
||||
result,
|
||||
children,
|
||||
}: {
|
||||
question: QuizQuestion;
|
||||
index: number;
|
||||
result?: QuestionResult;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isReview = !!result;
|
||||
const pts = question.points ?? 1;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-2xl border p-5 relative overflow-hidden',
|
||||
!isReview && 'border-gray-150 dark:border-gray-700 shadow-sm',
|
||||
isReview && result.status === 'correct' && 'border-emerald-200 dark:border-emerald-800 shadow-sm shadow-emerald-50 dark:shadow-emerald-900/20',
|
||||
isReview && result.status === 'incorrect' && 'border-red-200 dark:border-red-800 shadow-sm shadow-red-50 dark:shadow-red-900/20',
|
||||
)}
|
||||
>
|
||||
{/* Left accent */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 bottom-0 w-1 rounded-l-2xl',
|
||||
!isReview && 'bg-violet-400',
|
||||
isReview && result.status === 'correct' && 'bg-emerald-400',
|
||||
isReview && result.status === 'incorrect' && 'bg-red-400',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0',
|
||||
!isReview && 'bg-violet-100 dark:bg-violet-900/50 text-violet-600 dark:text-violet-400',
|
||||
isReview && result.status === 'correct' && 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400',
|
||||
isReview && result.status === 'incorrect' && 'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-100 leading-relaxed">{question.question}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{question.type === 'single' ? t('quiz.singleChoice') : question.type === 'multiple' ? t('quiz.multipleChoice') : t('quiz.shortAnswer')}
|
||||
{' · '}
|
||||
{pts} {t('quiz.pointsSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isReview && (
|
||||
<div className="shrink-0 ml-2">
|
||||
{result.status === 'correct' && <CheckCircle2 className="w-6 h-6 text-emerald-500" />}
|
||||
{result.status === 'incorrect' && <XCircle className="w-6 h-6 text-red-400" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{children}
|
||||
|
||||
{/* Analysis (review only) */}
|
||||
{isReview && question.analysis && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-blue-50/70 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
<span className="font-medium">{t('quiz.analysis')}</span>
|
||||
{question.analysis}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBanner({ score, total, results }: { score: number; total: number; results: QuestionResult[] }) {
|
||||
const { t } = useI18n();
|
||||
const pct = total > 0 ? Math.round((score / total) * 100) : 0;
|
||||
const correctCount = results.filter((r) => r.status === 'correct').length;
|
||||
const incorrectCount = results.filter((r) => r.status === 'incorrect').length;
|
||||
|
||||
const color = pct >= 80 ? 'emerald' : pct >= 60 ? 'amber' : 'red';
|
||||
const colorMap = {
|
||||
emerald: {
|
||||
bg: 'from-emerald-500 to-teal-500',
|
||||
shadow: 'shadow-emerald-200/50 dark:shadow-emerald-900/50',
|
||||
ring: 'bg-emerald-400/30',
|
||||
text: t('quiz.excellent'),
|
||||
},
|
||||
amber: {
|
||||
bg: 'from-amber-500 to-yellow-500',
|
||||
shadow: 'shadow-amber-200/50 dark:shadow-amber-900/50',
|
||||
ring: 'bg-amber-400/30',
|
||||
text: t('quiz.keepGoing'),
|
||||
},
|
||||
red: {
|
||||
bg: 'from-red-500 to-rose-500',
|
||||
shadow: 'shadow-red-200/50 dark:shadow-red-900/50',
|
||||
ring: 'bg-red-400/30',
|
||||
text: t('quiz.needsReview'),
|
||||
},
|
||||
};
|
||||
const c = colorMap[color];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={cn('rounded-2xl p-6 bg-gradient-to-r text-white shadow-lg', c.bg, c.shadow)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white/80 text-sm font-medium">{c.text}</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-4xl font-black">{score}</span>
|
||||
<span className="text-white/60 text-lg">/ {total}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> {correctCount} {t('quiz.correct')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="w-3.5 h-3.5" /> {incorrectCount} {t('quiz.incorrect')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Percentage ring */}
|
||||
<div className="relative w-20 h-20">
|
||||
<svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="6" />
|
||||
<motion.circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="34"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 34}`}
|
||||
initial={{ strokeDashoffset: 2 * Math.PI * 34 }}
|
||||
animate={{ strokeDashoffset: 2 * Math.PI * 34 * (1 - pct / 100) }}
|
||||
transition={{ duration: 1, ease: 'easeOut', delay: 0.3 }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-black">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────────────────────
|
||||
|
||||
export function QuizView({ questions, sceneId }: QuizViewProps) {
|
||||
const { t, locale } = useI18n();
|
||||
const [phase, setPhase] = useState<Phase>('not_started');
|
||||
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
|
||||
const [results, setResults] = useState<QuestionResult[]>([]);
|
||||
|
||||
// Draft cache for quiz answers, keyed by sceneId to isolate across classrooms
|
||||
const { cachedValue: cachedAnswers, updateCache: updateAnswersCache, clearCache: clearAnswersCache } = useDraftCache<Record<string, string | string[]>>({ key: `quizDraft:${sceneId}` });
|
||||
|
||||
// Restore cached answers during render (derived state pattern)
|
||||
const [prevCachedAnswers, setPrevCachedAnswers] = useState(cachedAnswers);
|
||||
if (cachedAnswers !== prevCachedAnswers) {
|
||||
setPrevCachedAnswers(cachedAnswers);
|
||||
if (cachedAnswers && Object.keys(cachedAnswers).length > 0 && phase === 'not_started') {
|
||||
setAnswers(cachedAnswers);
|
||||
setPhase('answering');
|
||||
}
|
||||
}
|
||||
|
||||
const totalPoints = useMemo(
|
||||
() => questions.reduce((sum, q) => sum + (q.points ?? 1), 0),
|
||||
[questions],
|
||||
);
|
||||
|
||||
const allAnswered = useMemo(() => {
|
||||
return questions.every((q) => {
|
||||
const a = answers[q.id];
|
||||
if (!a) return false;
|
||||
if (Array.isArray(a)) return a.length > 0;
|
||||
return (a as string).trim().length > 0;
|
||||
});
|
||||
}, [questions, answers]);
|
||||
|
||||
const handleSetAnswer = useCallback((questionId: string, value: string | string[]) => {
|
||||
setAnswers((prev) => {
|
||||
const next = { ...prev, [questionId]: value };
|
||||
updateAnswersCache(next);
|
||||
return next;
|
||||
});
|
||||
}, [updateAnswersCache]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setPhase('grading');
|
||||
clearAnswersCache();
|
||||
}, [clearAnswersCache]);
|
||||
|
||||
// When entering grading phase, grade choice questions locally + call API for short-answer
|
||||
useEffect(() => {
|
||||
if (phase !== 'grading') return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
// 1. Grade choice questions locally (instant)
|
||||
const choiceResults = gradeChoiceQuestions(questions, answers);
|
||||
|
||||
// 2. Grade short-answer questions via AI API (parallel)
|
||||
const shortAnswerQs = questions.filter(isShortAnswer);
|
||||
const aiResults = await Promise.all(
|
||||
shortAnswerQs.map((q) =>
|
||||
gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale),
|
||||
),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// 3. Merge results in original question order
|
||||
const allResultsMap = new Map<string, QuestionResult>();
|
||||
for (const r of [...choiceResults, ...aiResults]) {
|
||||
allResultsMap.set(r.questionId, r);
|
||||
}
|
||||
const ordered = questions.map((q) => allResultsMap.get(q.id)!).filter(Boolean);
|
||||
|
||||
|
||||
setResults(ordered);
|
||||
|
||||
setPhase('reviewing');
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [phase, questions, answers, locale]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase('not_started');
|
||||
setAnswers({});
|
||||
setResults([]);
|
||||
clearAnswersCache();
|
||||
}, [clearAnswersCache]);
|
||||
|
||||
const earnedScore = useMemo(
|
||||
() => results.reduce((sum, r) => sum + r.earned, 0),
|
||||
[results],
|
||||
);
|
||||
|
||||
const resultMap = useMemo(() => {
|
||||
const map: Record<string, QuestionResult> = {};
|
||||
results.forEach((r) => { map[r.questionId] = r; });
|
||||
return map;
|
||||
}, [results]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-900 overflow-hidden flex flex-col">
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'not_started' && (
|
||||
<motion.div
|
||||
key="cover"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="flex-1"
|
||||
>
|
||||
<QuizCover
|
||||
questionCount={questions.length}
|
||||
totalPoints={totalPoints}
|
||||
onStart={() => setPhase('answering')}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'answering' && (
|
||||
<motion.div
|
||||
key="answering"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4 text-violet-500" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('quiz.answering')}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
{Object.keys(answers).filter((k) => {
|
||||
const a = answers[k];
|
||||
if (Array.isArray(a)) return a.length > 0;
|
||||
return typeof a === 'string' && a.trim().length > 0;
|
||||
}).length} / {questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className={cn(
|
||||
'px-4 py-1.5 rounded-lg text-xs font-medium transition-all',
|
||||
allAnswered
|
||||
? 'bg-gradient-to-r from-violet-500 to-purple-500 text-white shadow-sm hover:shadow-md hover:shadow-violet-200/50 dark:hover:shadow-violet-900/50 active:scale-[0.97]'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{t('quiz.submitAnswers')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Questions */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{questions.map((q, i) => {
|
||||
if (q.type === 'single') {
|
||||
return (
|
||||
<SingleChoiceQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string | undefined}
|
||||
onChange={(v) => handleSetAnswer(q.id, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (q.type === 'multiple') {
|
||||
return (
|
||||
<MultipleChoiceQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string[] | undefined}
|
||||
onChange={(v) => handleSetAnswer(q.id, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ShortAnswerQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string | undefined}
|
||||
onChange={(v) => handleSetAnswer(q.id, v)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'grading' && (
|
||||
<motion.div
|
||||
key="grading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col items-center justify-center gap-5"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: 'linear' }}
|
||||
>
|
||||
<Loader2 className="w-10 h-10 text-violet-500" />
|
||||
</motion.div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-gray-700 dark:text-gray-200">{t('quiz.aiGrading')}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('quiz.aiGradingWait')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full bg-violet-400"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2, delay: i * 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'reviewing' && (
|
||||
<motion.div
|
||||
key="reviewing"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('quiz.quizReport')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('quiz.retry')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
<ScoreBanner score={earnedScore} total={totalPoints} results={results} />
|
||||
|
||||
{questions.map((q, i) => {
|
||||
const r = resultMap[q.id];
|
||||
if (q.type === 'single') {
|
||||
return (
|
||||
<SingleChoiceQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string | undefined}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
result={r}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (q.type === 'multiple') {
|
||||
return (
|
||||
<MultipleChoiceQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string[] | undefined}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
result={r}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ShortAnswerQuestion
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
value={answers[q.id] as string | undefined}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
result={r}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSettingsStore } from "@/lib/store/settings";
|
||||
|
||||
/**
|
||||
* Fetches server-configured providers on mount and merges into settings store.
|
||||
* Renders nothing — purely a side-effect component.
|
||||
*/
|
||||
export function ServerProvidersInit() {
|
||||
const fetchServerProviders = useSettingsStore(
|
||||
(state) => state.fetchServerProviders
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerProviders();
|
||||
}, [fetchServerProviders]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useI18n } from "@/lib/use-i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NewProviderData {
|
||||
name: string;
|
||||
type: "openai" | "anthropic" | "google";
|
||||
baseUrl: string;
|
||||
icon: string;
|
||||
requiresApiKey: boolean;
|
||||
}
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (provider: NewProviderData) => void;
|
||||
}
|
||||
|
||||
export function AddProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Internal state
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<"openai" | "anthropic" | "google">("openai");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [requiresApiKey, setRequiresApiKey] = useState(true);
|
||||
|
||||
// Reset form when dialog closes (derived state pattern)
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (!open) {
|
||||
setName("");
|
||||
setType("openai");
|
||||
setBaseUrl("");
|
||||
setIcon("");
|
||||
setRequiresApiKey(true);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
onAdd({
|
||||
name,
|
||||
type,
|
||||
baseUrl,
|
||||
icon,
|
||||
requiresApiKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogTitle className="sr-only">
|
||||
{t("settings.addProviderDialog")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("settings.addProviderDescription")}
|
||||
</DialogDescription>
|
||||
<div className="space-y-4">
|
||||
<div className="pb-3 border-b">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.addProviderDialog")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Provider Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.providerName")}</Label>
|
||||
<Input
|
||||
placeholder={t("settings.providerNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.providerApiMode")}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={() => setType("openai")}
|
||||
className={cn(
|
||||
"p-2 rounded-lg border text-left text-sm transition-colors",
|
||||
type === "openai"
|
||||
? "bg-primary/5 border-primary/50"
|
||||
: "hover:bg-muted/50 border-transparent"
|
||||
)}
|
||||
>
|
||||
{t("settings.apiModeOpenAI")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setType("anthropic")}
|
||||
className={cn(
|
||||
"p-2 rounded-lg border text-left text-sm transition-colors",
|
||||
type === "anthropic"
|
||||
? "bg-primary/5 border-primary/50"
|
||||
: "hover:bg-muted/50 border-transparent"
|
||||
)}
|
||||
>
|
||||
{t("settings.apiModeAnthropic")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setType("google")}
|
||||
className={cn(
|
||||
"p-2 rounded-lg border text-left text-sm transition-colors",
|
||||
type === "google"
|
||||
? "bg-primary/5 border-primary/50"
|
||||
: "hover:bg-muted/50 border-transparent"
|
||||
)}
|
||||
>
|
||||
{t("settings.apiModeGoogle")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.defaultBaseUrl")}</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon URL */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.providerIcon")}</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com/icon.svg"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requires API Key */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="requires-api-key"
|
||||
checked={requiresApiKey}
|
||||
onCheckedChange={(checked) => setRequiresApiKey(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="requires-api-key"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("settings.requiresApiKey")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
{t("settings.cancelEdit")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleAdd} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("settings.addProviderButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AlertCircle, User, Users, Sparkles, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/use-i18n";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
priority: number;
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
interface AgentSettingsProps {
|
||||
agents: Agent[];
|
||||
selectedAgentIds: string[];
|
||||
maxTurns: string;
|
||||
agentMode: 'preset' | 'auto';
|
||||
onToggleAgent: (agentId: string) => void;
|
||||
onMaxTurnsChange: (value: string) => void;
|
||||
onAgentModeChange: (mode: 'preset' | 'auto') => void;
|
||||
}
|
||||
|
||||
export function AgentSettings({
|
||||
agents,
|
||||
selectedAgentIds,
|
||||
maxTurns,
|
||||
agentMode,
|
||||
onToggleAgent,
|
||||
onMaxTurnsChange,
|
||||
onAgentModeChange,
|
||||
}: AgentSettingsProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const getAgentName = (agent: Agent) => {
|
||||
const key = `settings.agentNames.${agent.id}`;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : agent.name;
|
||||
};
|
||||
|
||||
const getAgentRole = (agent: Agent) => {
|
||||
const key = `settings.agentRoles.${agent.role}`;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : agent.role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.agentMode")}</Label>
|
||||
<div className="inline-flex rounded-lg border bg-muted/30 p-0.5">
|
||||
<button
|
||||
onClick={() => onAgentModeChange('preset')}
|
||||
className={cn(
|
||||
"px-4 py-1.5 text-sm font-medium rounded-md transition-all",
|
||||
agentMode === 'preset'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t("settings.agentModePreset")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAgentModeChange('auto')}
|
||||
className={cn(
|
||||
"px-4 py-1.5 text-sm font-medium rounded-md transition-all flex items-center gap-1.5",
|
||||
agentMode === 'auto'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("settings.agentModeAuto")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentMode === 'preset' ? (
|
||||
<>
|
||||
{/* Preset mode: existing agent multi-select */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("settings.selectAgents")}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.agentSettingsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border rounded-lg p-2 bg-muted/30">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-center space-x-3 p-3 rounded-lg border transition-all cursor-pointer",
|
||||
selectedAgentIds.includes(agent.id)
|
||||
? "bg-primary/10 border-primary/50 shadow-sm"
|
||||
: "bg-background hover:bg-muted/50 border-transparent"
|
||||
)}
|
||||
onClick={() => onToggleAgent(agent.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`agent-${agent.id}`}
|
||||
checked={selectedAgentIds.includes(agent.id)}
|
||||
onCheckedChange={() => onToggleAgent(agent.id)}
|
||||
disabled={agent.role === "teacher"}
|
||||
/>
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={agent.avatar} alt={getAgentName(agent)} />
|
||||
<AvatarFallback>{getAgentName(agent).charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm flex items-center gap-1.5">
|
||||
{getAgentName(agent)}
|
||||
{agent.role === "teacher" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 leading-none">
|
||||
{t("settings.required")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getAgentRole(agent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mode indicator */}
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
selectedAgentIds.length === 0
|
||||
? "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
: selectedAgentIds.length === 1
|
||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
: "bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800"
|
||||
}`}
|
||||
>
|
||||
{selectedAgentIds.length === 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("settings.atLeastOneAgent")}
|
||||
</span>
|
||||
)}
|
||||
{selectedAgentIds.length === 1 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="h-4 w-4" />
|
||||
<strong>{t("settings.singleAgentMode")}</strong> -{" "}
|
||||
{(() => {
|
||||
const agent = agents.find((a) => a.id === selectedAgentIds[0]);
|
||||
return agent ? getAgentName(agent) : t("settings.selectedAgent");
|
||||
})()}{" "}
|
||||
{t("settings.directAnswer")}
|
||||
</span>
|
||||
)}
|
||||
{selectedAgentIds.length > 1 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4" />
|
||||
<strong>{t("settings.multiAgentMode")}</strong> -{" "}
|
||||
{t("settings.agentsCollaboratingCount").replace("{count}", String(selectedAgentIds.length))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max turns config - only show for multi-agent */}
|
||||
{selectedAgentIds.length > 1 && (
|
||||
<div className="space-y-2 border-l-4 border-purple-500 pl-4">
|
||||
<Label>{t("settings.maxTurns")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.maxTurnsDesc")}
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxTurns}
|
||||
onChange={(e) => onMaxTurnsChange(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Auto mode: description */}
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800 text-sm">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{t("settings.agentModeAutoDesc")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/use-i18n";
|
||||
import { useSettingsStore } from "@/lib/store/settings";
|
||||
import { ASR_PROVIDERS } from "@/lib/audio/constants";
|
||||
import type { ASRProviderId } from "@/lib/audio/types";
|
||||
import { Mic, MicOff, CheckCircle2, XCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("ASRSettings");
|
||||
|
||||
interface ASRSettingsProps {
|
||||
selectedProviderId: ASRProviderId;
|
||||
}
|
||||
|
||||
export function ASRSettings({ selectedProviderId }: ASRSettingsProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const asrLanguage = useSettingsStore((state) => state.asrLanguage);
|
||||
const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig);
|
||||
const setASRProviderConfig = useSettingsStore((state) => state.setASRProviderConfig);
|
||||
|
||||
const asrProvider = ASR_PROVIDERS[selectedProviderId] ?? ASR_PROVIDERS["openai-whisper"];
|
||||
const isServerConfigured = !!asrProvidersConfig[selectedProviderId]?.isServerConfigured;
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [asrResult, setASRResult] = useState("");
|
||||
const [testStatus, setTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
|
||||
// Reset state when provider changes (derived state pattern)
|
||||
const [prevProviderId, setPrevProviderId] = useState(selectedProviderId);
|
||||
if (selectedProviderId !== prevProviderId) {
|
||||
setPrevProviderId(selectedProviderId);
|
||||
setShowApiKey(false);
|
||||
setTestStatus("idle");
|
||||
setTestMessage("");
|
||||
setASRResult("");
|
||||
}
|
||||
|
||||
const handleToggleASRRecording = async () => {
|
||||
if (isRecording) {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsRecording(false);
|
||||
} else {
|
||||
setASRResult("");
|
||||
setTestStatus("testing");
|
||||
setTestMessage("");
|
||||
|
||||
if (selectedProviderId === "browser-native") {
|
||||
|
||||
const SpeechRecognitionCtor = (window as unknown as Record<string, unknown>).SpeechRecognition || (window as unknown as Record<string, unknown>).webkitSpeechRecognition;
|
||||
if (!SpeechRecognitionCtor) {
|
||||
setTestStatus("error");
|
||||
setTestMessage(t("settings.asrNotSupported"));
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vendor-prefixed API without standard typings
|
||||
const recognition = new (SpeechRecognitionCtor as new () => any)();
|
||||
recognition.lang = asrLanguage || "zh-CN";
|
||||
recognition.onresult = (event: { results: { [index: number]: { [index: number]: { transcript: string } } } }) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setASRResult(transcript);
|
||||
setTestStatus("success");
|
||||
setTestMessage(t("settings.asrTestSuccess"));
|
||||
};
|
||||
recognition.onerror = (event: { error: string }) => {
|
||||
setTestStatus("error");
|
||||
setTestMessage(t("settings.asrTestFailed") + ": " + event.error);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setIsRecording(false);
|
||||
};
|
||||
recognition.start();
|
||||
setIsRecording(true);
|
||||
} else {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
const audioChunks: Blob[] = [];
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, "recording.webm");
|
||||
formData.append("providerId", selectedProviderId);
|
||||
formData.append("language", asrLanguage);
|
||||
const apiKeyValue = asrProvidersConfig[selectedProviderId]?.apiKey;
|
||||
if (apiKeyValue?.trim()) formData.append("apiKey", apiKeyValue);
|
||||
const baseUrlValue = asrProvidersConfig[selectedProviderId]?.baseUrl;
|
||||
if (baseUrlValue?.trim()) formData.append("baseUrl", baseUrlValue);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/transcription", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setASRResult(data.text);
|
||||
setTestStatus("success");
|
||||
setTestMessage(t("settings.asrTestSuccess"));
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }));
|
||||
setTestMessage(errorData.details || errorData.error || t("settings.asrTestFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("ASR test failed:", error);
|
||||
setTestStatus("error");
|
||||
setTestMessage(t("settings.asrTestFailed"));
|
||||
}
|
||||
};
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
log.error("Failed to access microphone:", error);
|
||||
setTestStatus("error");
|
||||
setTestMessage(t("settings.microphoneAccessFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Server-configured notice */}
|
||||
{isServerConfigured && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{t("settings.serverConfiguredNotice")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key & Base URL */}
|
||||
{(asrProvider.requiresApiKey || isServerConfigured) && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">{t("settings.asrApiKey")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
placeholder={isServerConfigured ? t("settings.optionalOverride") : t("settings.enterApiKey")}
|
||||
value={asrProvidersConfig[selectedProviderId]?.apiKey || ""}
|
||||
onChange={(e) => setASRProviderConfig(selectedProviderId, { apiKey: e.target.value })}
|
||||
className="font-mono text-sm pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">{t("settings.asrBaseUrl")}</Label>
|
||||
<Input
|
||||
placeholder={asrProvider.defaultBaseUrl || t("settings.enterCustomBaseUrl")}
|
||||
value={asrProvidersConfig[selectedProviderId]?.baseUrl || ""}
|
||||
onChange={(e) => setASRProviderConfig(selectedProviderId, { baseUrl: e.target.value })}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Request URL Preview */}
|
||||
{(() => {
|
||||
const effectiveBaseUrl = asrProvidersConfig[selectedProviderId]?.baseUrl || asrProvider.defaultBaseUrl || "";
|
||||
if (!effectiveBaseUrl) return null;
|
||||
let endpointPath = "";
|
||||
switch (selectedProviderId) {
|
||||
case "openai-whisper":
|
||||
endpointPath = "/audio/transcriptions";
|
||||
break;
|
||||
case "qwen-asr":
|
||||
endpointPath = "/services/aigc/multimodal-generation/generation";
|
||||
break;
|
||||
}
|
||||
if (!endpointPath) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{t("settings.requestUrl")}: {effectiveBaseUrl + endpointPath}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test ASR */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">{t("settings.testASR")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={asrResult}
|
||||
readOnly
|
||||
placeholder={t("settings.asrResultPlaceholder")}
|
||||
className="flex-1 bg-muted/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleToggleASRRecording}
|
||||
disabled={asrProvider.requiresApiKey && !asrProvidersConfig[selectedProviderId]?.apiKey?.trim() && !isServerConfigured}
|
||||
className="gap-2 w-[140px]"
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className="h-4 w-4" />
|
||||
{t("settings.stopRecording")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="h-4 w-4" />
|
||||
{t("settings.startRecording")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg p-3 text-sm overflow-hidden",
|
||||
testStatus === "success" &&
|
||||
"bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800",
|
||||
testStatus === "error" &&
|
||||
"bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{testStatus === "success" && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
|
||||
{testStatus === "error" && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
|
||||
<p className="flex-1 min-w-0 break-all">{testMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { useI18n } from "@/lib/use-i18n";
|
||||
import { clearDatabase } from "@/lib/utils/database";
|
||||
import { toast } from "sonner";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("GeneralSettings");
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Clear cache state
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [confirmInput, setConfirmInput] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const confirmPhrase = t("settings.clearCacheConfirmPhrase");
|
||||
const isConfirmValid = confirmInput === confirmPhrase;
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
if (!isConfirmValid) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
// 1. Clear IndexedDB
|
||||
await clearDatabase();
|
||||
// 2. Clear localStorage
|
||||
localStorage.clear();
|
||||
// 3. Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
|
||||
toast.success(t("settings.clearCacheSuccess"));
|
||||
|
||||
// Reload page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
log.error("Failed to clear cache:", error);
|
||||
toast.error(t("settings.clearCacheFailed"));
|
||||
setClearing(false);
|
||||
}
|
||||
}, [isConfirmValid, t]);
|
||||
|
||||
const clearCacheItems = t("settings.clearCacheConfirmItems").split("、").length > 1
|
||||
? t("settings.clearCacheConfirmItems").split("、")
|
||||
: t("settings.clearCacheConfirmItems").split(", ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Danger Zone - Clear Cache */}
|
||||
<div className="relative rounded-xl border border-destructive/30 bg-destructive/[0.03] dark:bg-destructive/[0.06] overflow-hidden">
|
||||
{/* Subtle diagonal stripe pattern for danger emphasis */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.015] dark:opacity-[0.03] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
currentColor 10px,
|
||||
currentColor 11px
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-md bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-destructive">
|
||||
{t("settings.dangerZone")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{t("settings.clearCache")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
|
||||
{t("settings.clearCacheDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setConfirmInput("");
|
||||
setShowClearDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
{t("settings.clearCache")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Cache Confirmation Dialog */}
|
||||
<AlertDialog open={showClearDialog} onOpenChange={(open) => {
|
||||
if (!clearing) {
|
||||
setShowClearDialog(open);
|
||||
if (!open) setConfirmInput("");
|
||||
}
|
||||
}}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
{t("settings.clearCacheConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>{t("settings.clearCacheConfirmDescription")}</p>
|
||||
<ul className="space-y-1.5 ml-1">
|
||||
{clearCacheItems.map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-destructive/60 shrink-0" />
|
||||
{item.trim()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-1">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
{t("settings.clearCacheConfirmInput")}
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
placeholder={confirmPhrase}
|
||||
value={confirmInput}
|
||||
onChange={(e) => setConfirmInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isConfirmValid) {
|
||||
handleClearCache();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={clearing}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isConfirmValid || clearing}
|
||||
onClick={handleClearCache}
|
||||
>
|
||||
{clearing ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
{t("settings.clearCacheButton")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user