mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-15 05:11:24 +08:00
Refactor: remove Canvas toolbar, extract model resolution, delete dead routes
- Delete CanvasToolbar, CanvasTool, and toolbar types entirely - Remove toolbar-related props/state from Stage, CanvasArea, Roundtable - Delete 3 obsolete generation routes (generate/, scene/, scene-outlines/) - Extract resolveModel/resolveModelFromHeaders into lib/server/resolve-model.ts reducing ~15 lines of boilerplate per route to 1 line (9 routes updated) - Delete unused lib/audio/qwen.html (156KB unreferenced doc snapshot) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
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'
|
||||
import { resolveModel } from '@/lib/server/resolve-model';
|
||||
const log = createLogger('Classroom')
|
||||
|
||||
|
||||
@@ -52,21 +51,14 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
const { model: languageModel, modelInfo } = resolveModel({
|
||||
modelString: model,
|
||||
apiKey: apiKey || '',
|
||||
baseUrl,
|
||||
providerType,
|
||||
requiresApiKey,
|
||||
});
|
||||
log.info(`Using model: ${model || 'gpt-4o-mini'}`);
|
||||
|
||||
const backgroundContext = background
|
||||
? `\n\nStudent background: ${background}\nPlease adapt the content complexity and examples based on this background.`
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
|
||||
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';
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
|
||||
const log = createLogger('Agent Profiles API');
|
||||
|
||||
@@ -56,25 +55,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
const { model: languageModel, modelString } = resolveModelFromHeaders(req);
|
||||
|
||||
// ── Build prompt ──
|
||||
const sceneSummary = sceneOutlines?.length
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
@@ -27,6 +25,7 @@ import type {
|
||||
import type { SpeechAction } from '@/lib/types/action';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
|
||||
const log = createLogger('Scene Actions API');
|
||||
|
||||
@@ -68,25 +67,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
const { model: languageModel, modelInfo, modelString } = resolveModelFromHeaders(req);
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
@@ -19,6 +17,7 @@ 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';
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
|
||||
const log = createLogger('Scene Content API');
|
||||
|
||||
@@ -68,25 +67,7 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
const { model: languageModel, modelInfo, modelString } = resolveModelFromHeaders(req);
|
||||
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -23,6 +21,7 @@ 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'
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
const log = createLogger('Outlines Stream')
|
||||
|
||||
|
||||
@@ -83,25 +82,7 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
});
|
||||
const { model: languageModel, modelInfo, modelString } = resolveModelFromHeaders(req);
|
||||
|
||||
if (!body.requirements) {
|
||||
return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required');
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model'
|
||||
const log = createLogger('PBL Chat')
|
||||
|
||||
|
||||
@@ -33,26 +32,8 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
})
|
||||
// Get model config from headers
|
||||
const { model } = resolveModelFromHeaders(req)
|
||||
|
||||
// Build context for the agent, differentiating question vs judge
|
||||
let issueContext = ''
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
|
||||
const log = createLogger('Quiz Grade')
|
||||
|
||||
|
||||
@@ -36,26 +35,8 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
});
|
||||
// Resolve model from request headers
|
||||
const { model: languageModel } = resolveModelFromHeaders(req);
|
||||
|
||||
const isZh = language === 'zh-CN';
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
import { resolveModel } from '@/lib/server/resolve-model';
|
||||
const log = createLogger('Verify Model')
|
||||
|
||||
|
||||
@@ -16,19 +15,13 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 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,
|
||||
const result = resolveModel({
|
||||
modelString: model,
|
||||
apiKey: apiKey || '',
|
||||
baseUrl: baseUrl || undefined,
|
||||
providerType,
|
||||
requiresApiKey,
|
||||
});
|
||||
languageModel = result.model;
|
||||
|
||||
@@ -7,15 +7,18 @@ import { cn } from '@/lib/utils';
|
||||
import { SceneRenderer } from '@/components/stage/scene-renderer';
|
||||
import { SceneProvider } from '@/lib/contexts/scene-context';
|
||||
import { Whiteboard } from '@/components/whiteboard';
|
||||
import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
|
||||
import type { CanvasToolbarProps } from '@/components/canvas/canvas-toolbar';
|
||||
import type { Scene, StageMode } from '@/lib/types/stage';
|
||||
import { useI18n } from '@/lib/hooks/use-i18n';
|
||||
|
||||
interface CanvasAreaProps extends CanvasToolbarProps {
|
||||
interface CanvasAreaProps {
|
||||
readonly currentScene: Scene | null;
|
||||
readonly currentSceneIndex: number;
|
||||
readonly mode: StageMode;
|
||||
readonly hideToolbar?: boolean;
|
||||
readonly engineState: 'idle' | 'playing' | 'paused';
|
||||
readonly isLiveSession?: boolean;
|
||||
readonly whiteboardOpen: boolean;
|
||||
readonly onPlayPause: () => void;
|
||||
readonly onWhiteboardClose: () => void;
|
||||
readonly isPendingScene?: boolean;
|
||||
readonly isGenerationFailed?: boolean;
|
||||
readonly onRetryGeneration?: () => void;
|
||||
@@ -24,22 +27,12 @@ interface CanvasAreaProps extends CanvasToolbarProps {
|
||||
export function CanvasArea({
|
||||
currentScene,
|
||||
currentSceneIndex,
|
||||
scenesCount,
|
||||
mode,
|
||||
engineState,
|
||||
isLiveSession,
|
||||
whiteboardOpen,
|
||||
sidebarCollapsed,
|
||||
chatCollapsed,
|
||||
onToggleSidebar,
|
||||
onToggleChat,
|
||||
onPrevSlide,
|
||||
onNextSlide,
|
||||
onPlayPause,
|
||||
onWhiteboardClose,
|
||||
showStopDiscussion,
|
||||
onStopDiscussion,
|
||||
hideToolbar,
|
||||
isPendingScene,
|
||||
isGenerationFailed,
|
||||
onRetryGeneration,
|
||||
@@ -189,31 +182,6 @@ export function CanvasArea({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'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/hooks/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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Mic, MicOff, Send, MessageSquare, Pause, Play, ChevronLeft, ChevronRight, Repeat, BookOpen, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
|
||||
import { useAudioRecorder } from '@/lib/hooks/use-audio-recorder';
|
||||
import { useI18n } from '@/lib/hooks/use-i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { useSettingsStore, PLAYBACK_SPEEDS } from '@/lib/store/settings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ProactiveCard } from '@/components/chat/proactive-card';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
|
||||
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
|
||||
@@ -44,24 +43,11 @@ interface RoundtableProps {
|
||||
readonly onMessageSend?: (message: string) => void;
|
||||
readonly onDiscussionStart?: (request: DiscussionAction) => void;
|
||||
readonly onDiscussionSkip?: () => void;
|
||||
readonly onStopDiscussion?: () => void;
|
||||
readonly onInputActivate?: () => void;
|
||||
readonly onSoftPause?: () => void;
|
||||
readonly onResumeTopic?: () => void;
|
||||
readonly onPlayPause?: () => void;
|
||||
readonly totalActions?: number;
|
||||
readonly currentActionIndex?: number;
|
||||
// Toolbar props (merged from CanvasArea)
|
||||
readonly currentSceneIndex?: number;
|
||||
readonly scenesCount?: number;
|
||||
readonly whiteboardOpen?: boolean;
|
||||
readonly sidebarCollapsed?: boolean;
|
||||
readonly chatCollapsed?: boolean;
|
||||
readonly onToggleSidebar?: () => void;
|
||||
readonly onToggleChat?: () => void;
|
||||
readonly onPrevSlide?: () => void;
|
||||
readonly onNextSlide?: () => void;
|
||||
readonly onWhiteboardClose?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_TEACHER_AVATAR = '/avatars/teacher.png';
|
||||
@@ -98,33 +84,13 @@ export function Roundtable({
|
||||
onMessageSend,
|
||||
onDiscussionStart,
|
||||
onDiscussionSkip,
|
||||
onStopDiscussion,
|
||||
onInputActivate,
|
||||
onSoftPause,
|
||||
onResumeTopic,
|
||||
onPlayPause,
|
||||
currentSceneIndex = 0,
|
||||
scenesCount = 1,
|
||||
whiteboardOpen = false,
|
||||
sidebarCollapsed,
|
||||
chatCollapsed,
|
||||
onToggleSidebar,
|
||||
onToggleChat,
|
||||
onPrevSlide,
|
||||
onNextSlide,
|
||||
onWhiteboardClose,
|
||||
}: RoundtableProps) {
|
||||
const { t } = useI18n();
|
||||
const ttsMuted = useSettingsStore((s) => s.ttsMuted);
|
||||
const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
|
||||
const ttsEnabled = useSettingsStore((state) => state.ttsEnabled);
|
||||
const asrEnabled = useSettingsStore((state) => state.asrEnabled);
|
||||
const ttsVolume = useSettingsStore((s) => s.ttsVolume);
|
||||
const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
|
||||
const autoPlayLecture = useSettingsStore((s) => s.autoPlayLecture);
|
||||
const setAutoPlayLecture = useSettingsStore((s) => s.setAutoPlayLecture);
|
||||
const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
|
||||
const setPlaybackSpeed = useSettingsStore((s) => s.setPlaybackSpeed);
|
||||
const [isInputOpen, setIsInputOpen] = useState(false);
|
||||
const [isVoiceOpen, setIsVoiceOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -281,10 +247,6 @@ export function Roundtable({
|
||||
}
|
||||
};
|
||||
|
||||
// Show stop button whenever there's an active QA/discussion session or live mode.
|
||||
// sessionType is only cleared in doSessionCleanup, so this stays stable through
|
||||
// brief loading gaps (e.g. between user message and agent SSE response).
|
||||
const showStopButton = engineMode === 'live' || sessionType === 'qa' || sessionType === 'discussion';
|
||||
|
||||
|
||||
// Determine active speaking state and bubble ownership
|
||||
@@ -333,46 +295,9 @@ export function Roundtable({
|
||||
bubbleRole === 'teacher' ? 'teacher' :
|
||||
'idle';
|
||||
|
||||
const handleCycleSpeed = useCallback(() => {
|
||||
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed as typeof PLAYBACK_SPEEDS[number]);
|
||||
const nextIndex = (currentIndex + 1) % PLAYBACK_SPEEDS.length;
|
||||
setPlaybackSpeed(PLAYBACK_SPEEDS[nextIndex]);
|
||||
}, [playbackSpeed, setPlaybackSpeed]);
|
||||
|
||||
return (
|
||||
<div className="h-[192px] w-full flex flex-col relative z-10 border-t border-gray-100 dark:border-gray-800 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md">
|
||||
{/* ── Toolbar strip — merged from CanvasArea ── */}
|
||||
<CanvasToolbar
|
||||
className="shrink-0 h-8 px-3 border-b border-gray-100/40 dark:border-gray-700/30"
|
||||
currentSceneIndex={currentSceneIndex}
|
||||
scenesCount={scenesCount}
|
||||
engineState={
|
||||
engineMode === 'playing' || engineMode === 'live' ? 'playing' :
|
||||
engineMode === 'paused' ? 'paused' : 'idle'
|
||||
}
|
||||
isLiveSession={isStreaming || isTopicPending || engineMode === 'live'}
|
||||
whiteboardOpen={whiteboardOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
chatCollapsed={chatCollapsed}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onToggleChat={onToggleChat}
|
||||
onPrevSlide={onPrevSlide ?? (() => {})}
|
||||
onNextSlide={onNextSlide ?? (() => {})}
|
||||
onPlayPause={onPlayPause ?? (() => {})}
|
||||
onWhiteboardClose={onWhiteboardClose ?? (() => {})}
|
||||
showStopDiscussion={showStopButton}
|
||||
onStopDiscussion={onStopDiscussion}
|
||||
ttsEnabled={ttsEnabled}
|
||||
ttsMuted={ttsMuted}
|
||||
ttsVolume={ttsVolume}
|
||||
onToggleMute={() => ttsEnabled && setTTSMuted(!ttsMuted)}
|
||||
onVolumeChange={(v) => setTTSVolume(v)}
|
||||
autoPlayLecture={autoPlayLecture}
|
||||
onToggleAutoPlay={() => setAutoPlayLecture(!autoPlayLecture)}
|
||||
playbackSpeed={playbackSpeed}
|
||||
onCycleSpeed={handleCycleSpeed}
|
||||
/>
|
||||
|
||||
{/* ── Interaction area — three-column layout ── */}
|
||||
<div className="flex-1 flex items-stretch min-h-0">
|
||||
{/* Left: Teacher identity */}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCanvasStore } from '@/lib/store/canvas';
|
||||
import { useSnapshotStore } from '@/lib/store/snapshot';
|
||||
import {
|
||||
Undo, Redo, Search, Type, Maximize, SquareMousePointer,
|
||||
ZoomOut, ZoomIn, LineSquiggle, Sigma, Omega,
|
||||
Shapes, ImageIcon, PieChart, Table2
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
const minZoom = 30;
|
||||
const maxZoom = 200;
|
||||
|
||||
export function CanvasTool() {
|
||||
// Canvas store state
|
||||
const _creatingElement = useCanvasStore.use.creatingElement();
|
||||
const _creatingCustomShape = useCanvasStore.use.creatingCustomShape();
|
||||
const _showSelectPanel = useCanvasStore.use.showSelectPanel();
|
||||
const _showSearchPanel = useCanvasStore.use.showSearchPanel();
|
||||
const canvasPercentage = useCanvasStore.use.canvasPercentage();
|
||||
const _canvasScale = useCanvasStore.use.canvasScale();
|
||||
|
||||
const setCreatingElement = useCanvasStore.use.setCreatingElement();
|
||||
const _setCreatingCustomShapeState = useCanvasStore.use.setCreatingCustomShapeState();
|
||||
const _setSelectPanelState = useCanvasStore.use.setSelectPanelState();
|
||||
const _setSearchPanelState = useCanvasStore.use.setSearchPanelState();
|
||||
const setCanvasPercentage = useCanvasStore.use.setCanvasPercentage();
|
||||
|
||||
const { canUndo: _canUndo, canRedo: _canRedo, undo: _undo, redo: _redo } = useSnapshotStore();
|
||||
|
||||
// Local state for popovers
|
||||
const [_moreVisible, _setMoreVisible] = useState(false);
|
||||
const [_textTypeVisible, _setTextTypeVisible] = useState(false);
|
||||
const [_shapePoolVisible, _setShapePoolVisible] = useState(false);
|
||||
const [_shapeMenuVisible, _setShapeMenuVisible] = useState(false);
|
||||
const [_imageMenuVisible, _setImageMenuVisible] = useState(false);
|
||||
const [_linePoolVisible, _setLinePoolVisible] = useState(false);
|
||||
const [_chartPoolVisible, _setChartPoolVisible] = useState(false);
|
||||
const [_tableGeneratorVisible, _setTableGeneratorVisible] = useState(false);
|
||||
const [_canvasScaleVisible, _setCanvasScaleVisible] = useState(false);
|
||||
|
||||
const _drawText = (vertical = false) => {
|
||||
setCreatingElement({ type: 'text', vertical });
|
||||
};
|
||||
|
||||
const _toggleSelectPanel = () => {
|
||||
_setSelectPanelState(!_showSelectPanel);
|
||||
};
|
||||
|
||||
const _toggleSearchPanel = () => {
|
||||
_setSearchPanelState(!_showSearchPanel);
|
||||
};
|
||||
|
||||
const onZoomIn = () => {
|
||||
setCanvasPercentage(Math.min(canvasPercentage + 10, 200));
|
||||
};
|
||||
|
||||
const onZoomOut = () => {
|
||||
setCanvasPercentage(Math.max(canvasPercentage - 10, 30));
|
||||
};
|
||||
|
||||
const onZoomReset = () => {
|
||||
setCanvasPercentage(90);
|
||||
};
|
||||
|
||||
const canZoomIn = canvasPercentage < maxZoom;
|
||||
const canZoomOut = canvasPercentage > minZoom;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b">
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Undo className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>撤销</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Redo className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>重做</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<SquareMousePointer className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>选择窗格</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>查找/替换</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Type className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入文字</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Shapes className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入形状</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ImageIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入图片</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<LineSquiggle className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入线条</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<PieChart className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入图表</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Table2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入表格</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Sigma className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入公式</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Omega className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>插入符号</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={onZoomOut} disabled={!canZoomOut}>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>缩小</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted rounded-md">
|
||||
<span className="text-sm font-medium min-w-[3ch] text-right">
|
||||
{Math.round(canvasPercentage)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={onZoomIn} disabled={!canZoomIn}>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>放大</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={onZoomReset} title="重置缩放">
|
||||
<Maximize className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>适应屏幕</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { CanvasTool } from "./CanvasTool";
|
||||
import Canvas from "./Canvas";
|
||||
import type { StageMode } from "@/lib/types/stage";
|
||||
import { ScreenCanvas } from "./ScreenCanvas";
|
||||
|
||||
/**
|
||||
* Slide Editor - wraps Canvas with SceneProvider
|
||||
*
|
||||
* Architecture:
|
||||
* - CanvasTool: Toolbar for Canvas operations
|
||||
* - Canvas: Main editing Canvas
|
||||
*/
|
||||
export function SlideEditor({ mode }: { readonly mode: StageMode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{mode === 'autonomous' && <CanvasTool />}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{mode === 'autonomous' ? <Canvas /> : <ScreenCanvas />}
|
||||
</div>
|
||||
|
||||
@@ -219,12 +219,6 @@ export function Stage({ onRetryOutline }: { onRetryOutline?: (outlineId: string)
|
||||
resetLiveState();
|
||||
}, [chatSessionType, resetLiveState]);
|
||||
|
||||
// Shared stop-discussion handler (used by both Roundtable and Canvas toolbar)
|
||||
const handleStopDiscussion = useCallback(async () => {
|
||||
await chatAreaRef.current?.endActiveSession();
|
||||
doSessionCleanup();
|
||||
}, [doSessionCleanup]);
|
||||
|
||||
// Initialize playback engine when scene changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -567,40 +561,12 @@ export function Stage({ onRetryOutline }: { onRetryOutline?: (outlineId: string)
|
||||
}
|
||||
};
|
||||
|
||||
// previous scene (gated)
|
||||
const handlePreviousScene = () => {
|
||||
if (isPendingScene) {
|
||||
// From pending page → go to last real scene
|
||||
if (scenes.length > 0) {
|
||||
gatedSceneSwitch(scenes[scenes.length - 1].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
|
||||
if (currentIndex > 0) {
|
||||
gatedSceneSwitch(scenes[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
// next scene (gated)
|
||||
const handleNextScene = () => {
|
||||
if (isPendingScene) return; // Already on pending, nowhere to go
|
||||
const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
|
||||
if (currentIndex < scenes.length - 1) {
|
||||
gatedSceneSwitch(scenes[currentIndex + 1].id);
|
||||
} else if (hasNextPending) {
|
||||
// On last real scene → advance to pending page
|
||||
setCurrentSceneId(PENDING_SCENE_ID);
|
||||
}
|
||||
};
|
||||
|
||||
// get scene information
|
||||
const isPendingScene = currentSceneId === PENDING_SCENE_ID;
|
||||
const hasNextPending = generatingOutlines.length > 0;
|
||||
const currentSceneIndex = isPendingScene
|
||||
? scenes.length
|
||||
: scenes.findIndex((s) => s.id === currentSceneId);
|
||||
const totalScenesCount = scenes.length + (hasNextPending ? 1 : 0);
|
||||
|
||||
// get action information
|
||||
const totalActions = currentScene?.actions?.length || 0;
|
||||
@@ -664,22 +630,12 @@ export function Stage({ onRetryOutline }: { onRetryOutline?: (outlineId: string)
|
||||
<CanvasArea
|
||||
currentScene={currentScene}
|
||||
currentSceneIndex={currentSceneIndex}
|
||||
scenesCount={totalScenesCount}
|
||||
mode={mode}
|
||||
engineState={canvasEngineState}
|
||||
isLiveSession={chatIsStreaming || isTopicPending || engineMode === 'live' || !!chatSessionType}
|
||||
whiteboardOpen={whiteboardOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
chatCollapsed={chatAreaCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
|
||||
onPrevSlide={handlePreviousScene}
|
||||
onNextSlide={handleNextScene}
|
||||
onPlayPause={handlePlayPause}
|
||||
onWhiteboardClose={handleWhiteboardToggle}
|
||||
showStopDiscussion={engineMode === 'live' || (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion'))}
|
||||
onStopDiscussion={handleStopDiscussion}
|
||||
hideToolbar={mode === 'playback'}
|
||||
isPendingScene={isPendingScene}
|
||||
isGenerationFailed={isPendingScene && failedOutlines.some(f => f.id === generatingOutlines[0]?.id)}
|
||||
onRetryGeneration={onRetryOutline && generatingOutlines[0] ? () => onRetryOutline(generatingOutlines[0].id) : undefined}
|
||||
@@ -744,7 +700,6 @@ export function Stage({ onRetryOutline }: { onRetryOutline?: (outlineId: string)
|
||||
// User clicks "Skip" on ProactiveCard
|
||||
engineRef.current?.skipDiscussion();
|
||||
}}
|
||||
onStopDiscussion={handleStopDiscussion}
|
||||
onInputActivate={async () => {
|
||||
// Soft-pause QA/Discussion if streaming (opening input = implicit pause)
|
||||
if (chatIsStreaming) {
|
||||
@@ -759,17 +714,6 @@ export function Stage({ onRetryOutline }: { onRetryOutline?: (outlineId: string)
|
||||
onResumeTopic={doResumeTopic}
|
||||
onPlayPause={handlePlayPause}
|
||||
totalActions={totalActions}
|
||||
currentActionIndex={0}
|
||||
currentSceneIndex={currentSceneIndex}
|
||||
scenesCount={totalScenesCount}
|
||||
whiteboardOpen={whiteboardOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
chatCollapsed={chatAreaCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
|
||||
onPrevSlide={handlePreviousScene}
|
||||
onNextSlide={handleNextScene}
|
||||
onWhiteboardClose={handleWhiteboardToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared model resolution utilities for API routes.
|
||||
*
|
||||
* Extracts the repeated parseModelString → resolveApiKey → resolveBaseUrl →
|
||||
* resolveProxy → getModel boilerplate into a single call.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { getModel, parseModelString, type ModelWithInfo } from '@/lib/ai/providers';
|
||||
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
|
||||
|
||||
export interface ResolvedModel extends ModelWithInfo {
|
||||
/** Original model string (e.g. "openai/gpt-4o-mini") */
|
||||
modelString: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a language model from explicit parameters.
|
||||
*
|
||||
* Use this when model config comes from the request body.
|
||||
*/
|
||||
export function resolveModel(params: {
|
||||
modelString?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
providerType?: string;
|
||||
requiresApiKey?: boolean;
|
||||
}): ResolvedModel {
|
||||
const modelString = params.modelString || 'gpt-4o-mini';
|
||||
const { providerId, modelId } = parseModelString(modelString);
|
||||
const apiKey = resolveApiKey(providerId, params.apiKey || '');
|
||||
const baseUrl = resolveBaseUrl(providerId, params.baseUrl);
|
||||
const proxy = resolveProxy(providerId);
|
||||
const { model, modelInfo } = getModel({
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
proxy,
|
||||
providerType: params.providerType as 'openai' | 'anthropic' | 'google' | undefined,
|
||||
requiresApiKey: params.requiresApiKey,
|
||||
});
|
||||
|
||||
return { model, modelInfo, modelString };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a language model from standard request headers.
|
||||
*
|
||||
* Reads: x-model, x-api-key, x-base-url, x-provider-type, x-requires-api-key
|
||||
*/
|
||||
export function resolveModelFromHeaders(req: NextRequest): ResolvedModel {
|
||||
return resolveModel({
|
||||
modelString: req.headers.get('x-model') || undefined,
|
||||
apiKey: req.headers.get('x-api-key') || undefined,
|
||||
baseUrl: req.headers.get('x-base-url') || undefined,
|
||||
providerType: req.headers.get('x-provider-type') || undefined,
|
||||
requiresApiKey: req.headers.get('x-requires-api-key') === 'true' ? true : undefined,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const enum ToolbarStates {
|
||||
EL_ANIMATION = 'elAnimation',
|
||||
EL_STYLE = 'elStyle',
|
||||
EL_POSITION = 'elPosition',
|
||||
SLIDE_DESIGN = 'slideDesign',
|
||||
SLIDE_ANIMATION = 'slideAnimation',
|
||||
MULTI_STYLE = 'multiStyle',
|
||||
MULTI_POSITION = 'multiPosition',
|
||||
}
|
||||
Reference in New Issue
Block a user