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:
wyuc
2026-03-12 22:06:33 +08:00
co-authored by Claude Opus 4.6
parent cc44f32ca8
commit 0610053d58
20 changed files with 96 additions and 1347 deletions
+7 -15
View File
@@ -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.`
+2 -21
View File
@@ -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
-120
View File
@@ -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));
}
}
+2 -21
View File
@@ -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;
+2 -21
View File
@@ -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');
-83
View File
@@ -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));
}
}
-191
View File
@@ -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));
}
}
+3 -22
View File
@@ -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 = ''
+3 -22
View File
@@ -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';
+6 -13
View File
@@ -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 -39
View File
@@ -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>
);
}
-358
View File
@@ -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>
);
}
+2 -77
View File
@@ -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>
-56
View File
@@ -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
+60
View File
@@ -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,
});
}
-9
View File
@@ -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',
}