563 lines
21 KiB
TypeScript
563 lines
21 KiB
TypeScript
// Large-course mode — two-layer course generation runner.
|
|
//
|
|
// The pipeline is split into two user-visible phases:
|
|
// 1. Framework phase (Layer 1): generate the course framework (optional
|
|
// web-search research first). Ends in `framework_ready` — the operator
|
|
// REVIEWS the framework and confirms before any module is generated.
|
|
// 2. Module phase (Layer 2): for each framework module (in order), run the
|
|
// EXISTING single-courseware generation agent via the classroom job
|
|
// infrastructure (`createClassroomGenerationJob` +
|
|
// `runClassroomGenerationJob`) and record per-module state. A failed
|
|
// module pauses later modules. Regenerating module N atomically
|
|
// invalidates N..end, then rebuilds that continuity chain in order. The
|
|
// whole course can be cancelled; running state is persisted so a restarted
|
|
// process can resume by re-running pending/failed modules (read-time
|
|
// reconciliation in the store).
|
|
//
|
|
// The runner itself is in-memory (like the classroom job runner); the course
|
|
// record on disk is the source of truth for resumability.
|
|
|
|
import { nanoid } from 'nanoid';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { callLLM } from '@/lib/ai/llm';
|
|
import { isProviderKeyRequired } from '@/lib/ai/providers';
|
|
import { resolveModel } from '@/lib/server/resolve-model';
|
|
import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-config';
|
|
import { buildSearchQuery } from '@/lib/server/search-query-builder';
|
|
import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search';
|
|
import { readClassroom } from '@/lib/server/classroom-storage';
|
|
import {
|
|
createClassroomGenerationJob,
|
|
readClassroomGenerationJob,
|
|
} from '@/lib/server/classroom-job-store';
|
|
import {
|
|
cancelClassroomGenerationJob,
|
|
runClassroomGenerationJob,
|
|
} from '@/lib/server/classroom-job-runner';
|
|
import type { GenerateClassroomInput } from '@/lib/server/classroom-generation';
|
|
import {
|
|
createCourseRecord,
|
|
invalidateCourseModulesFrom,
|
|
readCourseRecord,
|
|
readCourseRecordReconciled,
|
|
updateCourseModule,
|
|
updateCourseRecord,
|
|
} from './store';
|
|
import { generateCourseFramework, type FrameworkAICallFn } from './generate-framework';
|
|
import { buildModuleRequirement, emptyModuleRecord } from './types';
|
|
import { buildCourseModuleOutputDigest, formatPreviousModuleContext } from './module-digest';
|
|
import { acquireCourseGenerationActivity } from './publish-state';
|
|
import {
|
|
assertLearningCourseMutable,
|
|
FrozenLearningCourseMutationError,
|
|
} from '@/lib/makelore-course/immutability';
|
|
import type {
|
|
CourseCreateInput,
|
|
CourseModuleContinuityInputRef,
|
|
CourseModuleOutputDigest,
|
|
CourseModuleRecord,
|
|
CourseRecord,
|
|
} from './types';
|
|
|
|
const log = createLogger('CourseRunner');
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return (
|
|
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
(error instanceof Error && error.name === 'AbortError')
|
|
);
|
|
}
|
|
|
|
/** Runs tracked by composite key: `${courseId}` (framework) | `${courseId}:modules`
|
|
* (module pipeline) | `${courseId}:module:${index}` (regeneration cascade). */
|
|
const runningRuns = new Map<string, Promise<void>>();
|
|
const runningControllers = new Map<string, AbortController>();
|
|
/** courseId → classroom job ids currently running for that course (for cancel). */
|
|
const moduleJobsByCourse = new Map<string, Set<string>>();
|
|
|
|
export function isCourseGenerationRunning(courseId: string): boolean {
|
|
for (const key of runningRuns.keys()) {
|
|
if (key === courseId || key.startsWith(`${courseId}:`)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Cancel a course's generation: aborts the framework/module runs and
|
|
* cooperatively cancels the in-flight classroom job of the current module.
|
|
* Already-succeeded modules are kept.
|
|
*/
|
|
export function cancelCourseGenerationJob(courseId: string): boolean {
|
|
let cancelled = false;
|
|
for (const key of runningControllers.keys()) {
|
|
if (key !== courseId && !key.startsWith(`${courseId}:`)) continue;
|
|
runningControllers.get(key)?.abort();
|
|
cancelled = true;
|
|
}
|
|
const jobs = moduleJobsByCourse.get(courseId);
|
|
if (jobs) {
|
|
for (const jobId of jobs) cancelClassroomGenerationJob(jobId);
|
|
}
|
|
return cancelled;
|
|
}
|
|
|
|
/** Wrap one run in the running/controller maps; returns the run promise. */
|
|
function trackRun(
|
|
key: string,
|
|
courseId: string,
|
|
fn: (signal: AbortSignal) => Promise<void>,
|
|
): Promise<void> {
|
|
const existing = runningRuns.get(key);
|
|
if (existing) return existing;
|
|
|
|
for (const [activeKey] of runningRuns) {
|
|
if (activeKey !== courseId && !activeKey.startsWith(`${courseId}:`)) continue;
|
|
throw new Error(`Course ${courseId} already has an active generation run (${activeKey})`);
|
|
}
|
|
|
|
const releaseCourseActivity = acquireCourseGenerationActivity(courseId);
|
|
const controller = new AbortController();
|
|
runningControllers.set(key, controller);
|
|
|
|
const jobPromise = (async () => {
|
|
try {
|
|
await fn(controller.signal);
|
|
} catch (error) {
|
|
if (error instanceof FrozenLearningCourseMutationError) {
|
|
// An immutable-course preflight is not a failed generation attempt and
|
|
// must not mutate the already-frozen source record to `failed`.
|
|
throw error;
|
|
} else if (isAbortError(error)) {
|
|
log.info(`Course run cancelled: ${courseId} (${key})`);
|
|
await updateCourseRecord(courseId, { status: 'cancelled' }).catch(() => {});
|
|
} else {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log.error(`Course run failed (${courseId}, ${key}):`, error);
|
|
await updateCourseRecord(courseId, { status: 'failed', error: message }).catch(() => {});
|
|
}
|
|
} finally {
|
|
runningRuns.delete(key);
|
|
runningControllers.delete(key);
|
|
releaseCourseActivity();
|
|
const jobs = moduleJobsByCourse.get(courseId);
|
|
if (jobs && jobs.size === 0) moduleJobsByCourse.delete(courseId);
|
|
}
|
|
})();
|
|
|
|
runningRuns.set(key, jobPromise);
|
|
return jobPromise;
|
|
}
|
|
|
|
// ─── Public entry points ───────────────────────────────────────────────────
|
|
|
|
/** Phase 1: generate the course framework only. Ends `framework_ready`. */
|
|
export function runCourseFrameworkGeneration(courseId: string, baseUrl: string): Promise<void> {
|
|
return trackRun(courseId, courseId, async (signal) => {
|
|
await assertLearningCourseMutable(courseId);
|
|
const record = await readCourseRecordReconciled(courseId);
|
|
if (!record) throw new Error(`Course not found: ${courseId}`);
|
|
if (record.framework) return; // nothing to do — framework already exists
|
|
await runFrameworkPhase(courseId, baseUrl, signal);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Phase 2: generate every pending/failed module (skips succeeded). This is the
|
|
* operator's explicit confirmation action after reviewing the framework.
|
|
*/
|
|
export function runCourseModuleGeneration(courseId: string, baseUrl: string): Promise<void> {
|
|
return trackRun(`${courseId}:modules`, courseId, async (signal) => {
|
|
await assertLearningCourseMutable(courseId);
|
|
await runModulePhase(courseId, baseUrl, { signal });
|
|
});
|
|
}
|
|
|
|
/** Regenerate one module and every downstream module that depends on it. */
|
|
export function runCourseGenerationJob(
|
|
courseId: string,
|
|
baseUrl: string,
|
|
options: { onlyModuleIndex?: number } = {},
|
|
): Promise<void> {
|
|
const index = options.onlyModuleIndex;
|
|
if (index === undefined) throw new Error('runCourseGenerationJob requires onlyModuleIndex');
|
|
return trackRun(`${courseId}:module:${index}`, courseId, async (signal) => {
|
|
await assertLearningCourseMutable(courseId);
|
|
await runModulePhase(courseId, baseUrl, { onlyModuleIndex: index, signal });
|
|
});
|
|
}
|
|
|
|
/** Re-run the framework phase from scratch (resets module state). */
|
|
export function regenerateCourseFramework(courseId: string, baseUrl: string): Promise<void> {
|
|
return trackRun(courseId, courseId, async (signal) => {
|
|
await assertLearningCourseMutable(courseId);
|
|
const record = await readCourseRecordReconciled(courseId);
|
|
if (!record) throw new Error(`Course not found: ${courseId}`);
|
|
if (record.modules.some((m) => m.status === 'generating')) {
|
|
throw new Error('模块正在生成中,无法重新生成框架');
|
|
}
|
|
await updateCourseRecord(courseId, {
|
|
framework: undefined,
|
|
modules: [],
|
|
publication: undefined,
|
|
externalPublication: undefined,
|
|
});
|
|
await runFrameworkPhase(courseId, baseUrl, signal);
|
|
});
|
|
}
|
|
|
|
// ─── Phase implementations ─────────────────────────────────────────────────
|
|
|
|
/** Resolve the Layer-1 model + build the framework aiCall closure. */
|
|
async function buildFrameworkAiCall(signal: AbortSignal): Promise<FrameworkAICallFn> {
|
|
const {
|
|
model: languageModel,
|
|
modelInfo,
|
|
providerId,
|
|
apiKey,
|
|
thinkingConfig,
|
|
} = await resolveModel({ stage: 'course-framework' });
|
|
|
|
if (isProviderKeyRequired(providerId) && !apiKey) {
|
|
throw new Error(
|
|
`No API key configured for provider "${providerId}". ` +
|
|
`Set the appropriate key in .env.local or server-providers.yml.`,
|
|
);
|
|
}
|
|
|
|
return async (systemPrompt, userPrompt) => {
|
|
const result = await callLLM(
|
|
{
|
|
model: languageModel,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
maxOutputTokens: modelInfo?.outputWindow ?? 8192,
|
|
maxRetries: 0,
|
|
abortSignal: signal,
|
|
},
|
|
'course-framework',
|
|
undefined,
|
|
thinkingConfig,
|
|
);
|
|
return result.text;
|
|
};
|
|
}
|
|
|
|
/** Optional web-search research for the Layer-1 framework agent (graceful degradation). */
|
|
async function runFrameworkResearch(
|
|
record: { enableWebSearch?: boolean; pdfText?: string; requirement: string },
|
|
aiCall: FrameworkAICallFn,
|
|
): Promise<string | undefined> {
|
|
if (!record.enableWebSearch) return undefined;
|
|
const webSearchConfig = resolveClassroomWebSearchConfig({});
|
|
if (!webSearchConfig) {
|
|
log.warn('enableWebSearch is true but no web search API key configured; skipping research');
|
|
return undefined;
|
|
}
|
|
try {
|
|
const searchQuery = await buildSearchQuery(record.requirement, record.pdfText, aiCall);
|
|
const searchResult = await searchWeb({
|
|
providerId: webSearchConfig.providerId,
|
|
query: searchQuery.query,
|
|
apiKey: webSearchConfig.apiKey,
|
|
baseUrl: webSearchConfig.baseUrl,
|
|
baiduSubSources: webSearchConfig.baiduSubSources,
|
|
claudeModelId: webSearchConfig.claudeModelId,
|
|
});
|
|
const context = formatSearchResultsAsContext(searchResult);
|
|
if (context) log.info(`Course research returned ${searchResult.sources.length} sources`);
|
|
return context;
|
|
} catch (error) {
|
|
log.warn('Course framework research failed, continuing without search context:', error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Layer 1 — framework generation; ends with status `framework_ready`. */
|
|
async function runFrameworkPhase(
|
|
courseId: string,
|
|
baseUrl: string,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const record = await readCourseRecord(courseId);
|
|
if (!record) throw new Error(`Course not found: ${courseId}`);
|
|
|
|
await updateCourseRecord(courseId, { status: 'framework_generating', error: undefined });
|
|
log.info(`Course ${courseId}: generating framework (Layer 1)`);
|
|
|
|
const aiCall = await buildFrameworkAiCall(signal);
|
|
const researchContext = await runFrameworkResearch(record, aiCall);
|
|
|
|
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
|
|
|
|
const result = await generateCourseFramework(
|
|
{
|
|
requirement: record.requirement,
|
|
pdfText: record.pdfText,
|
|
researchContext,
|
|
},
|
|
aiCall,
|
|
);
|
|
|
|
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
|
|
|
|
if (!result.success || !result.data) {
|
|
throw new Error(`课程框架生成失败:${result.error ?? 'unknown'}`);
|
|
}
|
|
|
|
const { data: framework } = result;
|
|
const modules: CourseModuleRecord[] = framework.modules.map(emptyModuleRecord);
|
|
await updateCourseRecord(courseId, {
|
|
framework,
|
|
modules,
|
|
status: 'framework_ready',
|
|
error: undefined,
|
|
});
|
|
log.info(
|
|
`Course ${courseId}: framework ready — ${framework.modules.length} modules, awaiting confirmation`,
|
|
);
|
|
}
|
|
|
|
export interface RunModulePhaseOptions {
|
|
onlyModuleIndex?: number;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
async function buildPreviousModuleContext(
|
|
courseId: string,
|
|
record: CourseRecord,
|
|
currentModuleIndex: number,
|
|
): Promise<{ text: string; refs: CourseModuleContinuityInputRef[] }> {
|
|
const previousModules: Array<
|
|
Pick<CourseModuleRecord, 'index' | 'title'> & { outputDigest: CourseModuleOutputDigest }
|
|
> = [];
|
|
|
|
for (const moduleRec of [...record.modules].sort((a, b) => a.index - b.index)) {
|
|
if (moduleRec.index >= currentModuleIndex) break;
|
|
if (moduleRec.status !== 'succeeded' || !moduleRec.classroomId) {
|
|
throw new Error(
|
|
`Cannot generate module ${currentModuleIndex}: prior module ${moduleRec.index} has no successful actual output`,
|
|
);
|
|
}
|
|
|
|
let { outputDigest } = moduleRec;
|
|
if (!outputDigest) {
|
|
const classroom = await readClassroom(moduleRec.classroomId).catch(() => null);
|
|
if (!classroom) {
|
|
throw new Error(
|
|
`Cannot generate module ${currentModuleIndex}: prior classroom ${moduleRec.classroomId} is unavailable`,
|
|
);
|
|
}
|
|
outputDigest = buildCourseModuleOutputDigest(classroom);
|
|
await updateCourseModule(courseId, moduleRec.index, { outputDigest });
|
|
}
|
|
previousModules.push({
|
|
index: moduleRec.index,
|
|
title: moduleRec.title,
|
|
outputDigest,
|
|
});
|
|
}
|
|
|
|
return {
|
|
text: formatPreviousModuleContext(previousModules),
|
|
refs: previousModules.map((module) => ({
|
|
moduleIndex: module.index,
|
|
classroomId: module.outputDigest.classroomId,
|
|
semanticHash: module.outputDigest.semanticHash,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** Layer 2 — module generation, one module at a time via classroom jobs. */
|
|
async function runModulePhase(
|
|
courseId: string,
|
|
baseUrl: string,
|
|
options: RunModulePhaseOptions,
|
|
): Promise<void> {
|
|
const { onlyModuleIndex, signal = new AbortController().signal } = options;
|
|
|
|
let record = await readCourseRecordReconciled(courseId);
|
|
if (!record) throw new Error(`Course not found: ${courseId}`);
|
|
if (!record.framework) {
|
|
throw new Error('课程框架尚未生成,请先生成框架');
|
|
}
|
|
|
|
if (onlyModuleIndex !== undefined) {
|
|
// Clear the target and every dependent result in one persisted write before
|
|
// starting the target job. This immediately takes a completed course out of
|
|
// `completed` and prevents stale downstream classrooms from being treated
|
|
// as publishable while the cascade is running or paused after a failure.
|
|
record = await invalidateCourseModulesFrom(courseId, onlyModuleIndex);
|
|
const moduleRec = record.modules.find((m) => m.index === onlyModuleIndex);
|
|
if (!moduleRec) throw new Error(`Module ${onlyModuleIndex} not found`);
|
|
const targetSucceeded = await generateModule(courseId, moduleRec, baseUrl, signal);
|
|
if (targetSucceeded) {
|
|
const refreshed = await readCourseRecordReconciled(courseId);
|
|
for (const laterModule of refreshed?.modules ?? []) {
|
|
if (laterModule.index <= onlyModuleIndex) continue;
|
|
const succeeded = await generateModule(courseId, laterModule, baseUrl, signal);
|
|
if (!succeeded) break;
|
|
}
|
|
}
|
|
await finalizeCourse(courseId, signal);
|
|
return;
|
|
}
|
|
|
|
// Full module pipeline (confirmation / resume): mark generating explicitly so
|
|
// the status survives the brief all-pending window before module 1 starts.
|
|
await updateCourseRecord(courseId, { status: 'generating', error: undefined });
|
|
|
|
record = await readCourseRecordReconciled(courseId);
|
|
if (!record?.framework) throw new Error(`Course ${courseId}: framework generation failed`);
|
|
|
|
for (const moduleRec of record.modules) {
|
|
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
|
|
if (moduleRec.status === 'succeeded') continue;
|
|
const succeeded = await generateModule(courseId, moduleRec, baseUrl, signal);
|
|
if (!succeeded) break;
|
|
}
|
|
|
|
await finalizeCourse(courseId, signal);
|
|
}
|
|
|
|
/** Generate one module's courseware by reusing the single-courseware agent. */
|
|
async function generateModule(
|
|
courseId: string,
|
|
moduleRec: CourseModuleRecord,
|
|
baseUrl: string,
|
|
signal: AbortSignal,
|
|
): Promise<boolean> {
|
|
const record = await readCourseRecord(courseId);
|
|
const framework = record?.framework;
|
|
if (!record || !framework) {
|
|
throw new Error(
|
|
`Course ${courseId}: framework missing, cannot generate module ${moduleRec.index}`,
|
|
);
|
|
}
|
|
const spec = framework.modules.find((m) => m.index === moduleRec.index);
|
|
if (!spec) {
|
|
throw new Error(`Course ${courseId}: module ${moduleRec.index} not found in framework`);
|
|
}
|
|
|
|
const generationPrompt = moduleRec.generationPromptSnapshot?.trim() || spec.generationPrompt;
|
|
const generationSpec =
|
|
generationPrompt === spec.generationPrompt ? spec : { ...spec, generationPrompt };
|
|
const previousModuleContext = await buildPreviousModuleContext(courseId, record, moduleRec.index);
|
|
const requirement = buildModuleRequirement(
|
|
framework,
|
|
generationSpec,
|
|
framework.modules.length,
|
|
record.requirement,
|
|
previousModuleContext.text,
|
|
);
|
|
|
|
const input: GenerateClassroomInput = {
|
|
requirement,
|
|
...(record.enableWebSearch ? { enableWebSearch: true } : {}),
|
|
...(record.enableImageGeneration ? { enableImageGeneration: true } : {}),
|
|
...(record.enableVideoGeneration ? { enableVideoGeneration: true } : {}),
|
|
enableTTS: record.enableTTS ?? true,
|
|
interactiveMode: record.interactiveMode ?? true,
|
|
...(record.taskEngineMode ? { taskEngineMode: true } : {}),
|
|
};
|
|
|
|
const jobId = nanoid(10);
|
|
await createClassroomGenerationJob(jobId, input, {
|
|
ownerPrincipalId: record.ownerPrincipalId,
|
|
});
|
|
const jobs = moduleJobsByCourse.get(courseId) ?? new Set<string>();
|
|
jobs.add(jobId);
|
|
moduleJobsByCourse.set(courseId, jobs);
|
|
|
|
await updateCourseModule(courseId, moduleRec.index, {
|
|
status: 'generating',
|
|
jobId,
|
|
generationPromptSnapshot: generationPrompt,
|
|
continuityInputRefs: previousModuleContext.refs,
|
|
startedAt: new Date().toISOString(),
|
|
error: undefined,
|
|
completedAt: undefined,
|
|
});
|
|
|
|
if (signal.aborted) {
|
|
await updateCourseModule(courseId, moduleRec.index, { status: 'pending', jobId: undefined });
|
|
jobs.delete(jobId);
|
|
throw new DOMException('Course generation cancelled', 'AbortError');
|
|
}
|
|
|
|
log.info(
|
|
`Course ${courseId}: generating module ${moduleRec.index}/${framework.modules.length} "${spec.title}" (job ${jobId})`,
|
|
);
|
|
|
|
await runClassroomGenerationJob(jobId, input, baseUrl);
|
|
jobs.delete(jobId);
|
|
|
|
const job = await readClassroomGenerationJob(jobId);
|
|
if (job?.status === 'succeeded' && job.result?.classroomId) {
|
|
const classroom = await readClassroom(job.result.classroomId).catch(() => null);
|
|
if (!classroom) {
|
|
const error = `Generated classroom ${job.result.classroomId} could not be loaded for continuity digest`;
|
|
log.error(`Course ${courseId}: module ${moduleRec.index} failed — ${error}`);
|
|
await updateCourseModule(courseId, moduleRec.index, {
|
|
status: 'failed',
|
|
error,
|
|
jobId: undefined,
|
|
});
|
|
return false;
|
|
}
|
|
const outputDigest = buildCourseModuleOutputDigest(classroom);
|
|
await updateCourseModule(courseId, moduleRec.index, {
|
|
status: 'succeeded',
|
|
classroomId: job.result.classroomId,
|
|
jobId: undefined,
|
|
outputDigest,
|
|
completedAt: job.completedAt ?? new Date().toISOString(),
|
|
});
|
|
log.info(
|
|
`Course ${courseId}: module ${moduleRec.index} succeeded — classroom ${job.result.classroomId} (${job.result.scenesCount} scenes)`,
|
|
);
|
|
return true;
|
|
} else if (job?.status === 'cancelled') {
|
|
await updateCourseModule(courseId, moduleRec.index, { status: 'pending', jobId: undefined });
|
|
return false;
|
|
} else {
|
|
const error = job?.error ?? 'Module generation failed (unknown error)';
|
|
log.warn(`Course ${courseId}: module ${moduleRec.index} failed — ${error}`);
|
|
await updateCourseModule(courseId, moduleRec.index, {
|
|
status: 'failed',
|
|
error,
|
|
jobId: undefined,
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function finalizeCourse(courseId: string, signal: AbortSignal): Promise<void> {
|
|
if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError');
|
|
const record = await readCourseRecord(courseId);
|
|
if (!record) return;
|
|
if (record.modules.every((m) => m.status === 'succeeded')) {
|
|
await updateCourseRecord(courseId, { status: 'completed', error: undefined });
|
|
log.info(`Course ${courseId}: completed — ${record.modules.length} modules`);
|
|
}
|
|
}
|
|
|
|
/** Create a course record (does not start generation). */
|
|
export async function createCourse(
|
|
courseId: string,
|
|
input: CourseCreateInput,
|
|
ownership?: { ownerPrincipalId?: string },
|
|
): Promise<void> {
|
|
await createCourseRecord(courseId, input.requirement, {
|
|
ownerPrincipalId: ownership?.ownerPrincipalId,
|
|
enableWebSearch: input.enableWebSearch,
|
|
enableImageGeneration: input.enableImageGeneration,
|
|
enableVideoGeneration: input.enableVideoGeneration,
|
|
enableTTS: input.enableTTS ?? true,
|
|
interactiveMode: input.interactiveMode,
|
|
taskEngineMode: input.taskEngineMode,
|
|
pdfText: input.pdfContent?.text,
|
|
});
|
|
}
|