Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions app/api/generate/tts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const log = createLogger('TTS API');

export const maxDuration = 30;

function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
const trimmed = baseUrl?.trim();
return trimmed ? trimmed.replace(/\/+$/, '') : undefined;
}

export async function POST(req: NextRequest) {
let ttsProviderId: string | undefined;
let ttsVoice: string | undefined;
Expand Down Expand Up @@ -69,20 +74,24 @@ export async function POST(req: NextRequest) {
);
}

const clientBaseUrl = ttsBaseUrl || undefined;
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
const clientApiKey = ttsApiKey?.trim() ? ttsApiKey : undefined;
const requestedBaseUrl = normalizeBaseUrl(ttsBaseUrl);
const serverBaseUrl = normalizeBaseUrl(resolveTTSBaseUrl(ttsProviderId));
const useClientBaseUrl =
!!requestedBaseUrl &&
(!!clientApiKey || normalizeBaseUrl(requestedBaseUrl) !== normalizeBaseUrl(serverBaseUrl));

if (useClientBaseUrl) {
const ssrfError = await validateUrlForSSRF(requestedBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? ttsApiKey || ''
: resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined);
const baseUrl = clientBaseUrl
? clientBaseUrl
: resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined);
const apiKey = useClientBaseUrl
? clientApiKey || ''
: resolveTTSApiKey(ttsProviderId, clientApiKey);
const baseUrl = useClientBaseUrl ? requestedBaseUrl : serverBaseUrl || requestedBaseUrl;

// Build TTS config
const config = {
Expand Down
3 changes: 1 addition & 2 deletions components/settings/tts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,8 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
speed: ttsSpeed,
apiKey: ttsProvidersConfig[selectedProviderId]?.apiKey,
baseUrl:
ttsProvidersConfig[selectedProviderId]?.serverBaseUrl ||
ttsProvidersConfig[selectedProviderId]?.baseUrl ||
providerConfig?.customDefaultBaseUrl ||
(!isServerConfigured ? providerConfig?.customDefaultBaseUrl : '') ||
'',
providerOptions,
});
Expand Down
33 changes: 23 additions & 10 deletions lib/generation/scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
GeneratedQuizContent,
GeneratedInteractiveContent,
GeneratedPBLContent,
ScientificModel,

Check warning on line 17 in lib/generation/scene-generator.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Unit Tests

'ScientificModel' is defined but never used. Allowed unused vars must match /^_/u
PdfImage,
ImageMapping,
WidgetOutline,
Expand All @@ -32,7 +32,7 @@
import { parseJsonResponse } from './json-repair';
import {
buildCourseContext,
buildLanguageText,

Check warning on line 35 in lib/generation/scene-generator.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Unit Tests

'buildLanguageText' is defined but never used. Allowed unused vars must match /^_/u
formatAgentsForPrompt,
formatTeacherPersonaForPrompt,
formatImageDescription,
Expand Down Expand Up @@ -924,36 +924,49 @@
}
}

function stripTrailingCodeFence(html: string): string {
return html.replace(/\s*```\s*$/g, '').trim();
}

/**
* Extract HTML document from AI response.
* Tries to find <!DOCTYPE html>...</html> first, then falls back to code block extraction.
*/
function extractHtml(response: string): string | null {
export function extractHtml(response: string): string | null {
// Strategy 1: Find complete HTML document
const doctypeStart = response.indexOf('<!DOCTYPE html>');
const htmlTagStart = response.indexOf('<html');
const start = doctypeStart !== -1 ? doctypeStart : htmlTagStart;
const htmlStartMatch = /<!doctype\s+html>|<html\b/i.exec(response);
const start = htmlStartMatch?.index ?? -1;

if (start !== -1) {
const htmlEnd = response.lastIndexOf('</html>');
if (htmlEnd !== -1) {
const htmlEnd = response.toLowerCase().lastIndexOf('</html>');
if (htmlEnd >= start) {
return response.substring(start, htmlEnd + 7);
}
return stripTrailingCodeFence(response.substring(start));
}

// Strategy 2: Extract from code block
const codeBlockMatch = response.match(/```(?:html)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
const content = codeBlockMatch[1].trim();
const content = stripTrailingCodeFence(codeBlockMatch[1]);
if (content.includes('<html') || content.includes('<!DOCTYPE')) {
return content;
}
}

// Strategy 3: If response itself looks like HTML
// Strategy 3: Extract from an unterminated HTML code block
const unterminatedCodeBlockMatch = response.match(/```(?:html)?\s*([\s\S]*)$/);
if (unterminatedCodeBlockMatch) {
const content = stripTrailingCodeFence(unterminatedCodeBlockMatch[1]);
if (/<html\b/i.test(content) || /<!doctype\s+html>/i.test(content)) {
return content;
}
}

// Strategy 4: If response itself looks like HTML
const trimmed = response.trim();
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed;
if (/^<!doctype/i.test(trimmed) || /^<html\b/i.test(trimmed)) {
return stripTrailingCodeFence(trimmed);
}

log.error('Could not extract HTML from response');
Expand Down
20 changes: 19 additions & 1 deletion tests/generation/scene-generator-language-directive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
*/
import { describe, expect, it, vi, afterEach } from 'vitest';

import { generateSceneContent, generateSceneActions } from '@/lib/generation/scene-generator';
import {
extractHtml,
generateSceneContent,
generateSceneActions,
} from '@/lib/generation/scene-generator';
import { buildSceneFromOutline } from '@/lib/generation/scene-builder';
import type { AICallFn } from '@/lib/generation/pipeline-types';
import type {
Expand Down Expand Up @@ -290,3 +294,17 @@ describe('scene-generator language directive threading (issue #472)', () => {
});
});
});

describe('interactive HTML extraction', () => {
it('accepts an unterminated HTML code block when the model response is truncated', () => {
const html = extractHtml(`\`\`\`html
<!DOCTYPE html>
<html lang="zh-CN">
<head><title>Codex 命令全景图</title></head>
<body><main>visible widget</main>`);

expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('visible widget');
expect(html).not.toContain('```');
});
});
Loading