diff --git a/apps/editor/src/domain/documents/model.ts b/apps/editor/src/domain/documents/model.ts index 6ec6ce60..e553199d 100644 --- a/apps/editor/src/domain/documents/model.ts +++ b/apps/editor/src/domain/documents/model.ts @@ -76,9 +76,20 @@ export interface Page { animationBuilds?: ElementAnimationBuild[]; layoutId?: string; speakerNotes?: string; + semanticDescription?: SemanticSlideDescription; visible?: boolean; } +export interface SemanticSlideDescription { + text: string; + language: string; + generatedAt: string; + generator: string; + sourceRevision: string; + reviewed: boolean; + stale: boolean; +} + export type PageBackground = | { type: 'color'; color: string } | { type: 'asset'; assetId: string; colorFallback: string }; @@ -140,6 +151,7 @@ export interface ElementAnimationBuild { direction?: AnimationDirection; durationMs?: number; kind?: ElementAnimationKind; + order?: number; lineDrawDirection?: AnimationLineDrawDirection; mediaAction?: 'play'; } diff --git a/apps/editor/src/services/automation/authoringAutomationController.ts b/apps/editor/src/services/automation/authoringAutomationController.ts new file mode 100644 index 00000000..5948a8a1 --- /dev/null +++ b/apps/editor/src/services/automation/authoringAutomationController.ts @@ -0,0 +1,340 @@ +import type { ModelState } from '../contracts/interfaces'; +import type { SlideUpsertBatch, SlideUpsertResult } from './slideUpsertService'; +import { + AuthoringOperationRegistry, + type AuthoringOperationProgress, +} from './authoringOperationRegistry'; + +export type AuthoringResult = + | { ok: true; data: T } + | { ok: false; errorCode: string; message: string }; + +export interface AuthoringProgressReporter { + (progress: Partial): void; +} + +export interface AuthoringAutomationDelegate { + createPresentation(input: { + name?: string | undefined; + width?: number | undefined; + height?: number | undefined; + }): unknown; + getPresentationState(input: { + detail?: 'summary' | 'elements' | undefined; + slideNumbers?: number[] | undefined; + cursor?: number | undefined; + elementCursor?: number | undefined; + elementLimit?: number | undefined; + }): unknown; + importPowerPointFromUrl?( + input: { url: string; fileName?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; + translateDeckAndNotes?( + input: { targetLanguage: string; sourceLanguage?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; + generateDeckDetailedDescription?( + input: { + slideNumbers?: number[] | undefined; + language?: string | undefined; + force?: boolean | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + listAuthoringCatalog?(input: { + kind: 'fonts' | 'animations'; + elementType?: 'text' | 'image' | 'gif' | 'video' | 'shape' | undefined; + }): unknown; + upsertSlideContent(input: SlideUpsertBatch): Promise; + generateImage?( + input: { + prompt: string; + width?: number | undefined; + height?: number | undefined; + seed?: number | undefined; + steps?: number | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + getSlidePreview?(input: { slideNumber: number }): unknown; + getAiModelStatus?(): Promise; + prepareAiModels?( + input: { modelIds?: string[] | undefined }, + report: AuthoringProgressReporter, + ): Promise; + searchMedia?(input: { + kind: 'image' | 'gif'; + term: string; + limit?: number | undefined; + }): Promise; + exportPresentation?( + input: { + format: 'pptx' | 'pdf' | 'png' | 'jpeg'; + slideRange?: 'all' | 'current' | undefined; + includeAnimationFrames?: boolean | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + publishPresentation?( + input: { shareId?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; +} + +function success(data: T): AuthoringResult { + return { ok: true, data }; +} + +function failure(errorCode: string, message: string): AuthoringResult { + return { ok: false, errorCode, message }; +} + +function operationStarted(status: { operationId: string; state: string }) { + return success({ operationId: status.operationId, status: status.state }); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); +} + +class AuthoringAutomationController { + private readonly operations = new AuthoringOperationRegistry(); + private readonly upserts = new Map< + string, + { inputKey: string; promise: Promise } + >(); + + constructor(private readonly delegate: AuthoringAutomationDelegate) {} + + async createPresentation(input: { + name?: string | undefined; + width?: number | undefined; + height?: number | undefined; + }): Promise> { + try { + await Promise.allSettled([...this.upserts.values()].map(({ promise }) => promise)); + const result = await this.delegate.createPresentation(input); + this.upserts.clear(); + return success(result); + } catch (error) { + return failure('create_presentation', this.describeError(error)); + } + } + + async getPresentationState(input: { + detail?: 'summary' | 'elements' | undefined; + slideNumbers?: number[] | undefined; + cursor?: number | undefined; + elementCursor?: number | undefined; + elementLimit?: number | undefined; + }): Promise> { + try { + return success(await this.delegate.getPresentationState(input)); + } catch (error) { + return failure('get_presentation_state', this.describeError(error)); + } + } + + importPowerPointFromUrl(input: { url: string; fileName?: string | undefined }) { + if (!input.url.trim()) return failure('invalid_url', 'A PowerPoint URL is required.'); + if (!this.delegate.importPowerPointFromUrl) + return this.pending('import_powerpoint_from_url', 173); + const run = this.delegate.importPowerPointFromUrl.bind(this.delegate); + return operationStarted( + this.operations.start('fetching-powerpoint', (report) => run(input, report)), + ); + } + + translateDeckAndNotes(input: { targetLanguage: string; sourceLanguage?: string | undefined }) { + if (!input.targetLanguage.trim()) { + return failure('invalid_target_language', 'A target language is required.'); + } + if (!this.delegate.translateDeckAndNotes) return this.pending('translate_deck_and_notes', 174); + const run = this.delegate.translateDeckAndNotes.bind(this.delegate); + return operationStarted( + this.operations.start('preparing-translation', (report) => run(input, report)), + ); + } + + generateDeckDetailedDescription(input: { + slideNumbers?: number[] | undefined; + language?: string | undefined; + force?: boolean | undefined; + }) { + if (!this.delegate.generateDeckDetailedDescription) { + return this.pending('generate_deck_detailed_description', 174); + } + const run = this.delegate.generateDeckDetailedDescription.bind(this.delegate); + return operationStarted( + this.operations.start('describing-slides', (report) => run(input, report)), + ); + } + + async listAuthoringCatalog(input: { + kind: 'fonts' | 'animations'; + elementType?: 'text' | 'image' | 'gif' | 'video' | 'shape' | undefined; + }): Promise> { + if (!['fonts', 'animations'].includes(input.kind)) { + return failure('invalid_catalog', 'Catalog kind must be fonts or animations.'); + } + if (input.kind === 'animations' && !input.elementType) { + return failure('missing_element_type', 'Animation discovery requires elementType.'); + } + if (!this.delegate.listAuthoringCatalog) return this.pending('list_authoring_catalog', 175); + try { + return success(await this.delegate.listAuthoringCatalog(input)); + } catch (error) { + return failure('list_authoring_catalog', this.describeError(error)); + } + } + + async upsertSlideContent(input: SlideUpsertBatch): Promise> { + const cached = this.upserts.get(input.requestId); + const inputKey = JSON.stringify(canonicalize(input)); + if (cached) { + if (cached.inputKey !== inputKey) { + return failure( + 'request_id_conflict', + `requestId ${input.requestId} was already used with a different batch.`, + ); + } + try { + const result = await cached.promise; + return success({ ...result, project: undefined, idempotentReplay: true }); + } catch (error) { + return failure('upsert_slide_content', this.describeError(error)); + } + } + const promise = this.delegate.upsertSlideContent(input); + this.upserts.set(input.requestId, { inputKey, promise }); + try { + const result = await promise; + return success({ ...result, project: undefined, idempotentReplay: false }); + } catch (error) { + this.upserts.delete(input.requestId); + return failure('upsert_slide_content', this.describeError(error)); + } + } + + generateImage(input: { + prompt: string; + width?: number | undefined; + height?: number | undefined; + seed?: number | undefined; + steps?: number | undefined; + }) { + if (!input.prompt.trim()) return failure('empty_prompt', 'An image prompt is required.'); + if (!this.delegate.generateImage) return this.pending('generate_image', 175); + const run = this.delegate.generateImage.bind(this.delegate); + return operationStarted( + this.operations.start('generating-image', (report) => run(input, report)), + ); + } + + async getSlidePreview(input: { slideNumber: number }): Promise> { + if (!this.delegate.getSlidePreview) return this.pending('get_slide_preview', 176); + try { + return success(await this.delegate.getSlidePreview(input)); + } catch (error) { + return failure('get_slide_preview', this.describeError(error)); + } + } + + async getAiModelStatus(): Promise> { + if (!this.delegate.getAiModelStatus) return this.pending('get_ai_model_status', 175); + try { + return success(await this.delegate.getAiModelStatus()); + } catch (error) { + return failure('get_ai_model_status', this.describeError(error)); + } + } + + prepareAiModels(input: { modelIds?: string[] | undefined }) { + if (!this.delegate.prepareAiModels) return this.pending('prepare_ai_models', 175); + const run = this.delegate.prepareAiModels.bind(this.delegate); + return operationStarted( + this.operations.start('preparing-ai-models', (report) => run(input, report)), + ); + } + + async searchMedia(input: { + kind: 'image' | 'gif'; + term: string; + limit?: number | undefined; + }): Promise> { + if (!['image', 'gif'].includes(input.kind)) { + return failure('invalid_media_kind', 'Media kind must be image or gif.'); + } + if (!this.delegate.searchMedia) return this.pending('search_media', 175); + try { + return success(await this.delegate.searchMedia(input)); + } catch (error) { + return failure('search_media', this.describeError(error)); + } + } + + exportPresentation(input: { + format: 'pptx' | 'pdf' | 'png' | 'jpeg'; + slideRange?: 'all' | 'current' | undefined; + includeAnimationFrames?: boolean | undefined; + }) { + if (!['pptx', 'pdf', 'png', 'jpeg'].includes(input.format)) { + return failure('invalid_export_format', 'Export format must be pptx, pdf, png, or jpeg.'); + } + if (!this.delegate.exportPresentation) return this.pending('export_presentation', 176); + const run = this.delegate.exportPresentation.bind(this.delegate); + return operationStarted( + this.operations.start('exporting-presentation', (report) => run(input, report)), + ); + } + + publishPresentation(input: { shareId?: string | undefined }) { + if (!this.delegate.publishPresentation) return this.pending('publish_presentation', 177); + const run = this.delegate.publishPresentation.bind(this.delegate); + return operationStarted( + this.operations.start('publishing-presentation', (report) => run(input, report)), + ); + } + + async getOperationStatus(input: { + operationId: string; + waitForChangeMs?: number | undefined; + }): Promise> { + const initial = this.operations.get(input.operationId); + if (!initial) return failure('unknown_operation', `Unknown operation: ${input.operationId}.`); + const waitMs = Math.max(0, Math.min(5_000, input.waitForChangeMs ?? 0)); + if (waitMs > 0 && ['queued', 'running'].includes(initial.state)) { + const revision = initial.revision; + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + await new Promise((resolve) => + globalThis.setTimeout(resolve, Math.min(100, deadline - Date.now())), + ); + const next = this.operations.get(input.operationId); + if (!next || next.revision !== revision) break; + } + } + return success(this.operations.get(input.operationId)); + } + + private describeError(error: unknown) { + return error instanceof Error ? error.message : 'Authoring action failed.'; + } + + private pending(toolName: string, issueNumber: number): AuthoringResult { + return failure( + 'capability_pending', + `${toolName} is reserved in the authoring catalog and will be implemented in #${issueNumber}.`, + ); + } +} + +export const authoringAutomationController = { AuthoringAutomationController }; diff --git a/apps/editor/src/services/automation/authoringOperationRegistry.ts b/apps/editor/src/services/automation/authoringOperationRegistry.ts new file mode 100644 index 00000000..2d0a079d --- /dev/null +++ b/apps/editor/src/services/automation/authoringOperationRegistry.ts @@ -0,0 +1,107 @@ +export type AuthoringOperationState = 'queued' | 'running' | 'completed' | 'failed'; + +export interface AuthoringOperationProgress { + stage: string; + progress: number; + current?: number; + total?: number; + loadedBytes?: number; + totalBytes?: number; + detail?: string; + warnings?: string[]; +} + +export interface AuthoringOperationStatus extends AuthoringOperationProgress { + operationId: string; + percentage: number; + revision: number; + state: AuthoringOperationState; + createdAt: string; + updatedAt: string; + warnings: string[]; + error?: string; + result?: unknown; +} + +type OperationTask = ( + report: (progress: Partial) => void, +) => Promise; + +function createOperationId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `operation-${crypto.randomUUID()}`; + } + return `operation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function cloneStatus(status: AuthoringOperationStatus): AuthoringOperationStatus { + return { + ...status, + warnings: [...status.warnings], + }; +} + +export class AuthoringOperationRegistry { + private readonly operations = new Map(); + + start(stage: string, task: OperationTask): AuthoringOperationStatus { + const operationId = createOperationId(); + const timestamp = new Date().toISOString(); + const status: AuthoringOperationStatus = { + operationId, + percentage: 0, + revision: 0, + state: 'queued', + stage, + progress: 0, + createdAt: timestamp, + updatedAt: timestamp, + warnings: [], + }; + this.operations.set(operationId, status); + + void Promise.resolve().then(async () => { + this.update(operationId, { state: 'running', progress: 1 }); + try { + const result = await task((progress) => this.update(operationId, progress)); + this.update(operationId, { + state: 'completed', + stage: 'completed', + progress: 100, + result, + }); + } catch (error) { + this.update(operationId, { + state: 'failed', + stage: 'failed', + error: error instanceof Error ? error.message : 'Authoring operation failed.', + }); + } + }); + + return cloneStatus(status); + } + + get(operationId: string): AuthoringOperationStatus | undefined { + const status = this.operations.get(operationId); + return status ? cloneStatus(status) : undefined; + } + + private update(operationId: string, patch: Partial) { + const current = this.operations.get(operationId); + if (!current) return; + const progress = + patch.progress === undefined + ? current.progress + : Math.max(current.progress, Math.min(100, Math.round(patch.progress))); + this.operations.set(operationId, { + ...current, + ...patch, + progress, + percentage: progress, + revision: current.revision + 1, + updatedAt: new Date().toISOString(), + warnings: patch.warnings ? [...patch.warnings] : current.warnings, + }); + } +} diff --git a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts new file mode 100644 index 00000000..bbb7bd6c --- /dev/null +++ b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts @@ -0,0 +1,266 @@ +import type { Asset, ProjectDocument } from '../../domain/documents/model'; +import { sampleProject } from '../../domain/projects/sampleProject'; +import type { FontImportService } from '../contracts/interfaces'; +import { createPrefixedId } from '../ids/idUtils'; +import type { AuthoringAutomationDelegate } from './authoringAutomationController'; +import { + slideUpsertService, + type SlideMediaContentInput, + type SlideUpsertBatch, +} from './slideUpsertService'; + +interface CreateAuthoringDelegateOptions { + fontImportService: FontImportService; + getProject(): ProjectDocument; + replaceProject(project: ProjectDocument): void; + applyProject(project: ProjectDocument, activePageId?: string): void; +} + +const builtInFonts = new Set(['arial', 'inter', 'open sans', 'orbitron']); +const maxStateTextLength = 4_000; + +function boundedText(value: string | undefined) { + if (!value || value.length <= maxStateTextLength) return value; + return `${value.slice(0, maxStateTextLength)}…`; +} + +function boundedElement(element: ProjectDocument['elements'][string] | undefined) { + if (!element) return undefined; + return element.type === 'text' ? { ...element, text: boundedText(element.text) } : element; +} + +function validateRemoteUrl(value: string) { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Media URLs must be valid absolute URLs.'); + } + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Only HTTP and HTTPS media URLs are supported.'); + } + return url.toString(); +} + +function getSlideRevision(project: ProjectDocument, pageId: string) { + const page = project.pages.find((candidate) => candidate.id === pageId); + if (!page) return ''; + const elements = page.elementIds.map((elementId) => project.elements[elementId]); + const assetIds = new Set(); + if (page.background.type === 'asset') assetIds.add(page.background.assetId); + elements.forEach((element) => { + if (element && 'assetId' in element && typeof element.assetId === 'string') { + assetIds.add(element.assetId); + } + }); + const value = JSON.stringify({ + page: { + name: page.name, + width: page.width, + height: page.height, + background: page.background, + elementIds: page.elementIds, + transition: page.transition, + animationBuilds: page.animationBuilds, + layoutId: page.layoutId, + speakerNotes: page.speakerNotes, + visible: page.visible, + }, + elements, + assets: [...assetIds].sort().map((assetId) => project.assets[assetId]), + }); + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `slide-${(hash >>> 0).toString(16)}`; +} + +function createPresentationState( + project: ProjectDocument, + input: Parameters[0], +) { + const requestedNumbers = input.slideNumbers?.filter( + (slideNumber) => Number.isInteger(slideNumber) && slideNumber > 0, + ); + const cursor = Math.max(0, input.cursor ?? 0); + const candidates = requestedNumbers?.length + ? requestedNumbers + .map((slideNumber) => ({ page: project.pages[slideNumber - 1], slideNumber })) + .filter((entry): entry is { page: ProjectDocument['pages'][number]; slideNumber: number } => + Boolean(entry.page), + ) + : project.pages + .map((page, index) => ({ page, slideNumber: index + 1 })) + .slice(cursor, cursor + 20); + const detailed = input.detail === 'elements'; + const elementCursor = Math.floor(Math.max(0, input.elementCursor ?? 0)); + const elementLimit = Math.floor(Math.max(1, Math.min(50, input.elementLimit ?? 25))); + const slides = candidates.slice(0, detailed ? 5 : 20).map(({ page, slideNumber }) => { + const revision = getSlideRevision(project, page.id); + return { + slideId: page.id, + slideNumber, + name: page.name, + width: page.width, + height: page.height, + background: page.background, + elementCount: page.elementIds.length, + speakerNotes: boundedText(page.speakerNotes), + semanticDescription: page.semanticDescription + ? { ...page.semanticDescription, text: boundedText(page.semanticDescription.text) } + : undefined, + descriptionFreshness: page.semanticDescription + ? page.semanticDescription.stale || page.semanticDescription.sourceRevision !== revision + ? 'stale' + : 'fresh' + : 'missing', + revision, + ...(detailed + ? { + elements: page.elementIds + .slice(elementCursor, elementCursor + elementLimit) + .map((elementId) => boundedElement(project.elements[elementId])) + .filter(Boolean), + nextElementCursor: + elementCursor + elementLimit < page.elementIds.length + ? elementCursor + elementLimit + : undefined, + } + : {}), + }; + }); + return { + projectId: project.id, + name: project.name, + updatedAt: project.updatedAt, + revision: `${project.id}:${project.updatedAt}`, + pageCount: project.pages.length, + assetCount: Object.keys(project.assets).length, + recordingCount: Object.keys(project.recordings ?? {}).length, + slides, + nextCursor: + !requestedNumbers?.length && cursor + slides.length < project.pages.length + ? cursor + slides.length + : undefined, + }; +} + +export function createAuthoringAutomationDelegate( + options: CreateAuthoringDelegateOptions, +): AuthoringAutomationDelegate { + function resolveMedia( + input: SlideMediaContentInput, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ): Promise { + if (input.assetId) { + const asset = options.getProject().assets[input.assetId]; + if (!asset) throw new Error(`Unknown assetId: ${input.assetId}.`); + if (asset.type !== context.type && !(context.type === 'image' && asset.type === 'gif')) { + throw new Error(`Asset ${input.assetId} cannot be used as ${context.type}.`); + } + return Promise.resolve(asset); + } + if (input.mediaRef) { + throw new Error('mediaRef insertion will be enabled with search_media in #175.'); + } + if (!input.url) throw new Error(`${context.elementId} needs assetId or url.`); + return Promise.resolve({ + id: `asset-${context.elementId}`, + type: context.type, + name: `${context.type} for ${context.elementId}`, + mimeType: + context.type === 'video' + ? 'video/mp4' + : context.type === 'gif' + ? 'image/gif' + : 'image/jpeg', + objectUrl: validateRemoteUrl(input.url), + storage: 'remote', + }); + } + + return { + createPresentation(input) { + const width = input.width ?? 1920; + const height = input.height ?? 1080; + if (!Number.isFinite(width) || width < 1 || !Number.isFinite(height) || height < 1) { + throw new Error('Presentation dimensions must be positive finite numbers.'); + } + const blank = sampleProject.createBlankProject(); + const project = { + ...blank, + name: input.name?.trim() || blank.name, + pages: blank.pages.map((page) => ({ ...page, width, height })), + updatedAt: new Date().toISOString(), + }; + options.replaceProject(project); + return { + projectId: project.id, + name: project.name, + slideId: project.pages[0]?.id, + width, + height, + }; + }, + + getPresentationState(input) { + return createPresentationState(options.getProject(), input); + }, + + async upsertSlideContent(batch: SlideUpsertBatch) { + let project = options.getProject(); + slideUpsertService.validate(project, batch); + const requestedFonts = [ + ...new Map( + batch.elements + .filter((element) => element.type === 'text') + .map((element) => [ + `${element.style.fontFamily.trim().toLowerCase()}:${element.style.fontWeight}`, + { family: element.style.fontFamily.trim(), fontWeight: element.style.fontWeight }, + ]), + ).values(), + ].filter(({ family }) => family); + const projectFonts = Object.values(project.fonts ?? {}); + const missingFonts = requestedFonts.filter( + ({ family, fontWeight }) => + !builtInFonts.has(family.toLowerCase()) && + !projectFonts.some( + (font) => + font.family.toLowerCase() === family.toLowerCase() && font.fontWeight === fontWeight, + ), + ); + if (missingFonts.length) { + const fontResult = await options.fontImportService.resolveAndDownloadFonts( + missingFonts.map(({ family, fontWeight }) => ({ + family, + fontStyle: 'normal', + fontWeight, + })), + ); + const usableStatuses = new Set([ + 'available-system', + 'downloaded-exact', + 'downloaded-compatible', + ]); + const unresolved = fontResult.resolutions.filter( + (resolution) => !usableStatuses.has(resolution.status), + ); + if (unresolved.length) { + throw new Error( + `Fonts unavailable: ${unresolved.map((font) => font.requestedFamily).join(', ')}.`, + ); + } + project = { ...project, fonts: { ...(project.fonts ?? {}), ...fontResult.fonts } }; + await options.fontImportService.loadProjectFonts(project); + } + const result = await slideUpsertService.apply(project, batch, { + createId: createPrefixedId, + resolveMedia, + }); + options.applyProject(result.project, result.slideId); + return result; + }, + }; +} diff --git a/apps/editor/src/services/automation/slideUpsertService.ts b/apps/editor/src/services/automation/slideUpsertService.ts new file mode 100644 index 00000000..c71d60e1 --- /dev/null +++ b/apps/editor/src/services/automation/slideUpsertService.ts @@ -0,0 +1,548 @@ +import type { + Asset, + CropRect, + DesignElement, + ElementAnimationBuild, + ElementAnimationKind, + ElementType, + PageBackground, + ProjectDocument, + ShapeKind, +} from '../../domain/documents/model'; + +export interface SlideElementFrameInput { + x: number; + y: number; + width: number; + height: number; +} + +export interface SlideElementAnimationInput { + effect: ElementAnimationBuild['effect']; + trigger?: ElementAnimationBuild['trigger']; + kind?: ElementAnimationKind; + delayMs?: number; + durationMs?: number; + direction?: ElementAnimationBuild['direction']; + order: number; +} + +interface SlideElementBaseInput { + elementId: string; + type: ElementType; + frame: SlideElementFrameInput; + zIndex: number; + rotation?: number; + opacity?: number; + visible?: boolean; + locked?: boolean; + animations?: SlideElementAnimationInput[]; +} + +export interface SlideTextElementInput extends SlideElementBaseInput { + type: 'text'; + content: { text: string }; + style: { + fontFamily: string; + fontSize: number; + fontWeight: number; + color: string; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'bottom' | 'middle' | 'top'; + lineHeight?: number; + highlight?: string; + }; +} + +export interface SlideMediaContentInput { + assetId?: string; + url?: string; + mediaRef?: string; +} + +export interface SlideImageElementInput extends SlideElementBaseInput { + type: 'image'; + content: SlideMediaContentInput; + crop?: CropRect; + flipX?: boolean; + mask?: 'ellipse'; +} + +export interface SlideGifElementInput extends SlideElementBaseInput { + type: 'gif'; + content: SlideMediaContentInput; + playing?: boolean; +} + +export interface SlideVideoElementInput extends SlideElementBaseInput { + type: 'video'; + content: SlideMediaContentInput; + playback?: { + loop?: boolean; + controls?: boolean; + muted?: boolean; + autoplayInPreview?: boolean; + trimStartSeconds?: number; + trimEndSeconds?: number; + playAcrossSlides?: boolean; + startOnClick?: boolean; + volume?: number; + }; +} + +export interface SlideShapeElementInput extends SlideElementBaseInput { + type: 'shape'; + content: { + shape: ShapeKind; + fill?: string; + stroke?: string; + strokeWidth?: number; + }; +} + +export type SlideElementInput = + | SlideTextElementInput + | SlideImageElementInput + | SlideGifElementInput + | SlideVideoElementInput + | SlideShapeElementInput; + +export interface SlideUpsertBatch { + requestId: string; + slideId?: string; + slideNumber?: number; + mode: 'merge' | 'replace'; + slide?: { + name?: string; + width?: number; + height?: number; + background?: PageBackground; + speakerNotes?: string; + }; + elements: SlideElementInput[]; + deleteElementIds?: string[]; +} + +export interface SlideUpsertResult { + requestId: string; + slideId: string; + slideNumber: number; + createdSlide: boolean; + createdElements: number; + updatedElements: number; + deletedElements: number; + elementCount: number; + project: ProjectDocument; +} + +interface SlideUpsertOptions { + createId(prefix: string): string; + resolveMedia( + input: SlideMediaContentInput, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ): Promise; +} + +const shapeKinds = new Set([ + 'arc', + 'arrow', + 'diamond', + 'ellipse', + 'line', + 'parallelogram', + 'pentagon', + 'rect', + 'rounded-rect', + 'triangle', +]); +const animationEffects = new Set([ + 'blinds', + 'clothesline', + 'color-planes', + 'confetti', + 'cube', + 'doorway', + 'dissolve', + 'drop', + 'droplet', + 'fade', + 'fade-and-move', + 'fade-through-color', + 'fall', + 'flip', + 'flop', + 'grid', + 'iris', + 'keyboard-typing', + 'line-draw', + 'mosaic', + 'move-in', + 'page-flip', + 'pivot', + 'push', + 'radial-wipe', + 'reflection', + 'reveal', + 'revolving-door', + 'scale', + 'swap', + 'switch', + 'swoosh', + 'twirl', + 'twist', + 'wipe', +]); +const elementTypes = new Set(['text', 'image', 'gif', 'video', 'shape']); +const animationTriggers = new Set(['on-click', 'after-transition', 'after-previous']); +const animationKinds = new Set(['build-in', 'build-out', 'emphasis']); +const animationDirections = new Set(['up', 'right', 'down', 'left']); + +function requireFinite(value: number, label: string, minimum?: number) { + if (!Number.isFinite(value) || (minimum !== undefined && value < minimum)) { + throw new Error( + `${label} must be a finite number${minimum === undefined ? '' : ` greater than or equal to ${minimum}`}.`, + ); + } +} + +function validateElement(input: SlideElementInput) { + if (!input.elementId.trim()) throw new Error('Every element needs a stable elementId.'); + if (!elementTypes.has(input.type)) + throw new Error(`${input.elementId} uses an unsupported type.`); + requireFinite(input.frame.x, `${input.elementId}.frame.x`); + requireFinite(input.frame.y, `${input.elementId}.frame.y`); + requireFinite(input.frame.width, `${input.elementId}.frame.width`, 1); + requireFinite(input.frame.height, `${input.elementId}.frame.height`, 1); + requireFinite(input.zIndex, `${input.elementId}.zIndex`, 0); + if (!Number.isInteger(input.zIndex)) + throw new Error(`${input.elementId}.zIndex must be an integer.`); + if (input.opacity !== undefined) { + requireFinite(input.opacity, `${input.elementId}.opacity`, 0); + if (input.opacity > 1) throw new Error(`${input.elementId}.opacity must be at most 1.`); + } + for (const animation of input.animations ?? []) { + if (!animationEffects.has(animation.effect)) { + throw new Error(`${input.elementId} uses an unsupported animation effect.`); + } + requireFinite(animation.order, `${input.elementId}.animation.order`, 0); + if (!Number.isInteger(animation.order)) { + throw new Error(`${input.elementId}.animation.order must be an integer.`); + } + if (animation.trigger && !animationTriggers.has(animation.trigger)) { + throw new Error(`${input.elementId} uses an unsupported animation trigger.`); + } + if (animation.kind && !animationKinds.has(animation.kind)) { + throw new Error(`${input.elementId} uses an unsupported animation kind.`); + } + if (animation.direction && !animationDirections.has(animation.direction)) { + throw new Error(`${input.elementId} uses an unsupported animation direction.`); + } + if (animation.delayMs !== undefined) { + requireFinite(animation.delayMs, `${input.elementId}.animation.delayMs`, 0); + } + if (animation.durationMs !== undefined) { + requireFinite(animation.durationMs, `${input.elementId}.animation.durationMs`, 0); + } + } + if (input.type === 'text') { + if (!input.style.fontFamily.trim()) throw new Error(`${input.elementId} needs a font family.`); + requireFinite(input.style.fontSize, `${input.elementId}.style.fontSize`, 1); + requireFinite(input.style.fontWeight, `${input.elementId}.style.fontWeight`, 1); + } + if (input.type === 'shape' && !shapeKinds.has(input.content.shape)) { + throw new Error(`${input.elementId} uses an unsupported shape.`); + } + if (input.type === 'image' || input.type === 'gif' || input.type === 'video') { + const sources = [input.content.assetId, input.content.url, input.content.mediaRef].filter( + Boolean, + ); + if (sources.length !== 1) { + throw new Error(`${input.elementId} needs exactly one of assetId, url, or mediaRef.`); + } + } +} + +function commonElement(input: SlideElementInput) { + return { + id: input.elementId, + type: input.type, + ...input.frame, + rotation: input.rotation ?? 0, + opacity: Math.min(1, input.opacity ?? 1), + visible: input.visible ?? true, + locked: input.locked ?? false, + }; +} + +async function createElement( + input: SlideElementInput, + options: SlideUpsertOptions, +): Promise<{ asset?: Asset; element: DesignElement }> { + const common = commonElement(input); + if (input.type === 'text') { + return { + element: { + ...common, + type: 'text', + text: input.content.text, + fontFamily: input.style.fontFamily, + fontSize: input.style.fontSize, + fontWeight: input.style.fontWeight, + fill: input.style.color, + align: input.style.align ?? 'left', + ...(input.style.verticalAlign ? { verticalAlign: input.style.verticalAlign } : {}), + ...(input.style.lineHeight !== undefined ? { lineHeight: input.style.lineHeight } : {}), + ...(input.style.highlight ? { highlight: input.style.highlight } : {}), + }, + }; + } + if (input.type === 'shape') { + return { + element: { + ...common, + type: 'shape', + shape: input.content.shape, + ...(input.content.fill ? { fill: input.content.fill } : {}), + ...(input.content.stroke ? { stroke: input.content.stroke } : {}), + ...(input.content.strokeWidth !== undefined + ? { strokeWidth: input.content.strokeWidth } + : {}), + }, + }; + } + + const asset = await options.resolveMedia(input.content, { + elementId: input.elementId, + type: input.type, + }); + if (input.type === 'image') { + return { + asset, + element: { + ...common, + type: 'image', + assetId: asset.id, + ...(input.crop ? { crop: input.crop } : {}), + ...(input.flipX !== undefined ? { flipX: input.flipX } : {}), + ...(input.mask ? { mask: input.mask } : {}), + }, + }; + } + if (input.type === 'gif') { + return { + asset, + element: { + ...common, + type: 'gif', + assetId: asset.id, + playing: input.playing ?? true, + }, + }; + } + return { + asset, + element: { + ...common, + type: 'video', + assetId: asset.id, + loop: input.playback?.loop ?? false, + controls: input.playback?.controls ?? true, + muted: input.playback?.muted ?? false, + autoplayInPreview: input.playback?.autoplayInPreview ?? false, + trimStartSeconds: input.playback?.trimStartSeconds ?? 0, + ...(input.playback?.trimEndSeconds !== undefined + ? { trimEndSeconds: input.playback.trimEndSeconds } + : {}), + ...(input.playback?.playAcrossSlides !== undefined + ? { playAcrossSlides: input.playback.playAcrossSlides } + : {}), + ...(input.playback?.startOnClick !== undefined + ? { startOnClick: input.playback.startOnClick } + : {}), + ...(input.playback?.volume !== undefined ? { volume: input.playback.volume } : {}), + }, + }; +} + +function resolvePageIndex(project: ProjectDocument, batch: SlideUpsertBatch) { + if (batch.slideId) { + const index = project.pages.findIndex((page) => page.id === batch.slideId); + if (index < 0) throw new Error(`Unknown slideId: ${batch.slideId}.`); + return { index, created: false }; + } + if (!Number.isInteger(batch.slideNumber) || (batch.slideNumber ?? 0) < 1) { + throw new Error('Provide a valid one-based slideNumber or slideId.'); + } + const index = (batch.slideNumber ?? 1) - 1; + if (index > project.pages.length) throw new Error('Slide numbers cannot contain gaps.'); + return { index, created: index === project.pages.length }; +} + +async function apply( + project: ProjectDocument, + batch: SlideUpsertBatch, + options: SlideUpsertOptions, +): Promise { + validate(project, batch); + const target = resolvePageIndex(project, batch); + const existingPage = project.pages[target.index]; + const slideId = existingPage?.id ?? options.createId('page'); + const previousElementIds = existingPage?.elementIds ?? []; + const deleteIds = new Set(batch.deleteElementIds ?? []); + if (batch.mode === 'replace') previousElementIds.forEach((elementId) => deleteIds.add(elementId)); + const upsertElementIds = new Set(batch.elements.map((element) => element.elementId)); + const deletedElementIds = [...deleteIds].filter((elementId) => !upsertElementIds.has(elementId)); + + const resolvedElements = await Promise.all( + batch.elements.map(async (input) => ({ input, ...(await createElement(input, options)) })), + ); + + const elements = { ...project.elements }; + deleteIds.forEach((elementId) => delete elements[elementId]); + const assets = { ...project.assets }; + resolvedElements.forEach(({ asset, element }) => { + elements[element.id] = element; + if (asset) assets[asset.id] = asset; + }); + + const retainedIds = previousElementIds.filter( + (elementId) => + !deleteIds.has(elementId) && !batch.elements.some((item) => item.elementId === elementId), + ); + const zIndexes = new Map(batch.elements.map((element) => [element.elementId, element.zIndex])); + const orderedElementIds = [ + ...retainedIds, + ...batch.elements.map((element) => element.elementId), + ].sort( + (first, second) => + (zIndexes.get(first) ?? previousElementIds.indexOf(first)) - + (zIndexes.get(second) ?? previousElementIds.indexOf(second)), + ); + const animations = batch.elements + .flatMap((element) => + (element.animations ?? []).map((animation) => ({ elementId: element.elementId, animation })), + ) + .sort((first, second) => first.animation.order - second.animation.order) + .map(({ elementId, animation }) => ({ + id: options.createId(`animation-${elementId}`), + elementId, + effect: animation.effect, + trigger: animation.trigger ?? 'on-click', + delayMs: animation.delayMs ?? 0, + ...(animation.kind ? { kind: animation.kind } : {}), + order: animation.order, + ...(animation.durationMs !== undefined ? { durationMs: animation.durationMs } : {}), + ...(animation.direction ? { direction: animation.direction } : {}), + })); + const retainedAnimations = (existingPage?.animationBuilds ?? []).filter( + (build) => + !deleteIds.has(build.elementId) && + !batch.elements.some((item) => item.elementId === build.elementId), + ); + const orderedAnimations = [...retainedAnimations, ...animations].sort( + (first, second) => (first.order ?? 0) - (second.order ?? 0), + ); + const timestamp = new Date().toISOString(); + const page = { + id: slideId, + name: batch.slide?.name ?? existingPage?.name ?? `Slide ${target.index + 1}`, + width: batch.slide?.width ?? existingPage?.width ?? 1920, + height: batch.slide?.height ?? existingPage?.height ?? 1080, + background: batch.slide?.background ?? + existingPage?.background ?? { type: 'color' as const, color: '#050D10' }, + elementIds: orderedElementIds, + ...(batch.slide?.speakerNotes !== undefined + ? { speakerNotes: batch.slide.speakerNotes } + : existingPage?.speakerNotes + ? { speakerNotes: existingPage.speakerNotes } + : {}), + ...(orderedAnimations.length > 0 ? { animationBuilds: orderedAnimations } : {}), + ...(existingPage?.semanticDescription + ? { semanticDescription: { ...existingPage.semanticDescription, stale: true } } + : {}), + visible: existingPage?.visible ?? true, + }; + const pages = [...project.pages]; + if (target.created) pages.push(page); + else pages[target.index] = page; + const nextProject = { ...project, pages, elements, assets, updatedAt: timestamp }; + + return { + requestId: batch.requestId, + slideId, + slideNumber: target.index + 1, + createdSlide: target.created, + createdElements: batch.elements.filter((element) => !project.elements[element.elementId]) + .length, + updatedElements: batch.elements.filter((element) => + Boolean(project.elements[element.elementId]), + ).length, + deletedElements: deletedElementIds.length, + elementCount: orderedElementIds.length, + project: nextProject, + }; +} + +function validate(project: ProjectDocument, batch: SlideUpsertBatch) { + if (!batch.requestId?.trim()) throw new Error('requestId is required for idempotent upserts.'); + if (!['merge', 'replace'].includes(batch.mode)) throw new Error('mode must be merge or replace.'); + if (Boolean(batch.slideId) === Boolean(batch.slideNumber)) { + throw new Error('Provide exactly one of slideId or slideNumber.'); + } + if (!Array.isArray(batch.elements)) throw new Error('elements must be an array.'); + if (batch.elements.length > 100) throw new Error('A batch can contain at most 100 elements.'); + if ((batch.deleteElementIds?.length ?? 0) > 100) { + throw new Error('A batch can delete at most 100 elements.'); + } + if (batch.slide?.width !== undefined) requireFinite(batch.slide.width, 'slide.width', 1); + if (batch.slide?.height !== undefined) requireFinite(batch.slide.height, 'slide.height', 1); + if ( + batch.slide?.background?.type === 'asset' && + !project.assets[batch.slide.background.assetId] + ) { + throw new Error(`Unknown background assetId: ${batch.slide.background.assetId}.`); + } + const elementIds = new Set(); + batch.elements.forEach((element) => { + validateElement(element); + if (elementIds.has(element.elementId)) + throw new Error(`Duplicate elementId: ${element.elementId}.`); + elementIds.add(element.elementId); + }); + const animationOrders = batch.elements.flatMap((element) => + (element.animations ?? []).map((animation) => animation.order), + ); + if (new Set(animationOrders).size !== animationOrders.length) { + throw new Error('Animation order values must be unique within a batch.'); + } + + const target = resolvePageIndex(project, batch); + const existingPage = project.pages[target.index]; + const slideId = existingPage?.id ?? 'new-slide'; + const foreignElementIds = new Set( + project.pages.filter((page) => page.id !== slideId).flatMap((page) => page.elementIds), + ); + for (const elementId of elementIds) { + if (foreignElementIds.has(elementId)) { + throw new Error(`elementId ${elementId} belongs to another slide.`); + } + } + + const previousElementIds = existingPage?.elementIds ?? []; + const deleteIds = new Set(batch.deleteElementIds ?? []); + if (deleteIds.size !== (batch.deleteElementIds?.length ?? 0)) { + throw new Error('deleteElementIds cannot contain duplicates.'); + } + for (const elementId of elementIds) { + if (deleteIds.has(elementId)) { + throw new Error(`Cannot upsert and delete ${elementId} in the same batch.`); + } + } + for (const elementId of deleteIds) { + if (!previousElementIds.includes(elementId)) { + throw new Error(`Cannot delete ${elementId}; it does not belong to the target slide.`); + } + } +} + +export const slideUpsertService = { apply, validate }; diff --git a/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts b/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts new file mode 100644 index 00000000..04ac9f5f --- /dev/null +++ b/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts @@ -0,0 +1,276 @@ +const frameSchema = { + type: 'object', + additionalProperties: false, + required: ['x', 'y', 'width', 'height'], + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number', minimum: 1 }, + height: { type: 'number', minimum: 1 }, + }, +}; + +const cropSchema = { + type: 'object', + additionalProperties: false, + required: ['x', 'y', 'width', 'height'], + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number', minimum: 0 }, + height: { type: 'number', minimum: 0 }, + }, +}; + +const animationSchema = { + type: 'object', + additionalProperties: false, + required: ['effect', 'order'], + properties: { + effect: { + type: 'string', + enum: [ + 'blinds', + 'clothesline', + 'color-planes', + 'confetti', + 'cube', + 'doorway', + 'dissolve', + 'drop', + 'droplet', + 'fade', + 'fade-and-move', + 'fade-through-color', + 'fall', + 'flip', + 'flop', + 'grid', + 'iris', + 'keyboard-typing', + 'line-draw', + 'mosaic', + 'move-in', + 'page-flip', + 'pivot', + 'push', + 'radial-wipe', + 'reflection', + 'reveal', + 'revolving-door', + 'scale', + 'swap', + 'switch', + 'swoosh', + 'twirl', + 'twist', + 'wipe', + ], + }, + trigger: { type: 'string', enum: ['on-click', 'after-transition', 'after-previous'] }, + kind: { type: 'string', enum: ['build-in', 'build-out', 'emphasis'] }, + delayMs: { type: 'number', minimum: 0 }, + durationMs: { type: 'number', minimum: 0 }, + direction: { type: 'string', enum: ['up', 'right', 'down', 'left'] }, + order: { type: 'integer', minimum: 0 }, + }, +}; + +const commonProperties = { + elementId: { type: 'string', minLength: 1, maxLength: 500 }, + frame: frameSchema, + zIndex: { type: 'integer', minimum: 0 }, + rotation: { type: 'number' }, + opacity: { type: 'number', minimum: 0, maximum: 1 }, + visible: { type: 'boolean' }, + locked: { type: 'boolean' }, + animations: { type: 'array', maxItems: 50, items: animationSchema }, +}; + +const mediaContentSchema = { + type: 'object', + additionalProperties: false, + properties: { + assetId: { type: 'string', minLength: 1, maxLength: 500 }, + url: { type: 'string', minLength: 1, maxLength: 8000 }, + mediaRef: { type: 'string', minLength: 1, maxLength: 500 }, + }, + oneOf: [{ required: ['assetId'] }, { required: ['url'] }, { required: ['mediaRef'] }], +}; + +function elementSchema( + type: 'gif' | 'image' | 'shape' | 'text' | 'video', + properties: Record, + required: string[], +) { + return { + type: 'object', + additionalProperties: false, + required: ['elementId', 'type', 'frame', 'zIndex', ...required], + properties: { + ...commonProperties, + type: { const: type }, + ...properties, + }, + }; +} + +const backgroundSchema = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + required: ['type', 'color'], + properties: { type: { const: 'color' }, color: { type: 'string' } }, + }, + { + type: 'object', + additionalProperties: false, + required: ['type', 'assetId', 'colorFallback'], + properties: { + type: { const: 'asset' }, + assetId: { type: 'string' }, + colorFallback: { type: 'string' }, + }, + }, + ], +}; + +export const slideUpsertInputSchema = { + type: 'object', + additionalProperties: false, + required: ['requestId', 'mode', 'elements'], + oneOf: [ + { required: ['slideId'], not: { required: ['slideNumber'] } }, + { required: ['slideNumber'], not: { required: ['slideId'] } }, + ], + properties: { + requestId: { type: 'string', minLength: 1, maxLength: 500 }, + slideId: { type: 'string', minLength: 1, maxLength: 500 }, + slideNumber: { type: 'integer', minimum: 1 }, + mode: { type: 'string', enum: ['merge', 'replace'] }, + slide: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 500 }, + width: { type: 'number', minimum: 1, maximum: 10000 }, + height: { type: 'number', minimum: 1, maximum: 10000 }, + background: backgroundSchema, + speakerNotes: { type: 'string', maxLength: 100000 }, + }, + }, + elements: { + type: 'array', + maxItems: 100, + items: { + oneOf: [ + elementSchema( + 'text', + { + content: { + type: 'object', + additionalProperties: false, + required: ['text'], + properties: { text: { type: 'string', maxLength: 100000 } }, + }, + style: { + type: 'object', + additionalProperties: false, + required: ['fontFamily', 'fontSize', 'fontWeight', 'color'], + properties: { + fontFamily: { type: 'string', minLength: 1, maxLength: 500 }, + fontSize: { type: 'number', minimum: 1 }, + fontWeight: { type: 'number', minimum: 1 }, + color: { type: 'string' }, + align: { type: 'string', enum: ['left', 'center', 'right'] }, + verticalAlign: { type: 'string', enum: ['bottom', 'middle', 'top'] }, + lineHeight: { type: 'number', minimum: 0 }, + highlight: { type: 'string' }, + }, + }, + }, + ['content', 'style'], + ), + elementSchema( + 'image', + { + content: mediaContentSchema, + crop: cropSchema, + flipX: { type: 'boolean' }, + mask: { const: 'ellipse' }, + }, + ['content'], + ), + elementSchema( + 'gif', + { + content: mediaContentSchema, + playing: { type: 'boolean' }, + }, + ['content'], + ), + elementSchema( + 'video', + { + content: mediaContentSchema, + playback: { + type: 'object', + additionalProperties: false, + properties: { + loop: { type: 'boolean' }, + controls: { type: 'boolean' }, + muted: { type: 'boolean' }, + autoplayInPreview: { type: 'boolean' }, + trimStartSeconds: { type: 'number', minimum: 0 }, + trimEndSeconds: { type: 'number', minimum: 0 }, + playAcrossSlides: { type: 'boolean' }, + startOnClick: { type: 'boolean' }, + volume: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + ['content'], + ), + elementSchema( + 'shape', + { + content: { + type: 'object', + additionalProperties: false, + required: ['shape'], + properties: { + shape: { + type: 'string', + enum: [ + 'arc', + 'arrow', + 'diamond', + 'ellipse', + 'line', + 'parallelogram', + 'pentagon', + 'rect', + 'rounded-rect', + 'triangle', + ], + }, + fill: { type: 'string' }, + stroke: { type: 'string' }, + strokeWidth: { type: 'number', minimum: 0 }, + }, + }, + }, + ['content'], + ), + ], + }, + }, + deleteElementIds: { + type: 'array', + maxItems: 100, + uniqueItems: true, + items: { type: 'string', minLength: 1, maxLength: 500 }, + }, + }, +}; diff --git a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts index b91a2319..99a51d7f 100644 --- a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts +++ b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts @@ -1,19 +1,26 @@ -import type { - AutomationResult, - GenerateImageAutomationInput, - TranslateTextAutomationInput, -} from '../automation/editorAutomationController'; -import { editorAutomationController } from '../automation/editorAutomationController'; +import type { AuthoringResult } from '../automation/authoringAutomationController'; +import { authoringAutomationController } from '../automation/authoringAutomationController'; +import type { SlideUpsertBatch } from '../automation/slideUpsertService'; import { promptRecipes } from '../../ui/editor/prompting/promptRecipes'; +import { slideUpsertInputSchema } from './slideUpsertInputSchema'; type ToolInput = Record; -type EditorAutomationController = InstanceType; +type AuthoringAutomationController = InstanceType< + typeof authoringAutomationController.AuthoringAutomationController +>; + +export interface WebMcpToolAnnotations { + readOnlyHint?: boolean; + untrustedContentHint?: boolean; +} export interface WebMcpTool { + annotations?: WebMcpToolAnnotations; description: string; - execute(input: ToolInput): Promise> | AutomationResult; + execute(input: ToolInput): Promise> | AuthoringResult; inputSchema: Record; name: string; + title: string; } export interface WebMcpModelContext { @@ -25,18 +32,13 @@ export interface WebMcpDemoWindow extends Window { localStudioWebMcpTools?: WebMcpTool[]; } -type ControllerLike = Pick< - EditorAutomationController, - 'createProject' | 'generateSlides' | 'generateImage' | 'translateText' | 'getProjectSnapshot' ->; - -function promptExamplesList(examples: readonly string[]) { - return examples.map((example) => `- ${example}`).join('\n'); +function stringInput(input: ToolInput, key: string) { + return typeof input[key] === 'string' ? input[key] : ''; } -function stringInput(input: ToolInput, key: string) { - const value = input[key]; - return typeof value === 'string' ? value : ''; +function optionalStringInput(input: ToolInput, key: string) { + const value = stringInput(input, key).trim(); + return value || undefined; } function optionalNumberInput(input: ToolInput, key: string) { @@ -44,26 +46,22 @@ function optionalNumberInput(input: ToolInput, key: string) { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function imageInput(input: ToolInput): GenerateImageAutomationInput { - const height = optionalNumberInput(input, 'height'); - const seed = optionalNumberInput(input, 'seed'); - const steps = optionalNumberInput(input, 'steps'); - const width = optionalNumberInput(input, 'width'); - return { - prompt: stringInput(input, 'prompt'), - ...(height !== undefined ? { height } : {}), - ...(seed !== undefined ? { seed } : {}), - ...(steps !== undefined ? { steps } : {}), - ...(width !== undefined ? { width } : {}), - }; +function optionalBooleanInput(input: ToolInput, key: string) { + return typeof input[key] === 'boolean' ? input[key] : undefined; } -function translateInput(input: ToolInput): TranslateTextAutomationInput { - return { - scope: stringInput(input, 'scope'), - targetLanguage: stringInput(input, 'targetLanguage'), - ...(stringInput(input, 'pageId') ? { pageId: stringInput(input, 'pageId') } : {}), - }; +function optionalStringArrayInput(input: ToolInput, key: string) { + const value = input[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined; +} + +function optionalNumberArrayInput(input: ToolInput, key: string) { + const value = input[key]; + return Array.isArray(value) + ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) + : undefined; } function isCleanupCallback(value: unknown): value is () => void { @@ -75,75 +73,379 @@ function isDuplicateToolNameError(error: unknown) { return error.name === 'InvalidStateError' && error.message.includes('Duplicate tool name'); } -function collectCleanup(cleanups: Array<() => void>, value: unknown) { - if (isCleanupCallback(value)) cleanups.push(value); -} +const emptyObjectSchema = { type: 'object', additionalProperties: false, properties: {} }; +const operationAnnotations = { readOnlyHint: false, untrustedContentHint: true }; +const readerAnnotations = { readOnlyHint: true, untrustedContentHint: true }; export class WebMcpToolAdapter { - constructor(private readonly controller: ControllerLike) {} + constructor(private readonly controller: AuthoringAutomationController) {} createTools(): WebMcpTool[] { return [ { - name: 'create_project', - description: 'Create a new blank LocalStudio.dev project in the active editor tab.', + name: 'create_presentation', + title: 'Create presentation', + description: + 'Create a blank LocalStudio presentation with an explicit name and optional canvas dimensions.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 200 }, + width: { type: 'number', minimum: 1, maximum: 10000 }, + height: { type: 'number', minimum: 1, maximum: 10000 }, + }, + }, + execute: (input) => + this.controller.createPresentation({ + ...(optionalStringInput(input, 'name') + ? { name: optionalStringInput(input, 'name') } + : {}), + ...(optionalNumberInput(input, 'width') !== undefined + ? { width: optionalNumberInput(input, 'width') } + : {}), + ...(optionalNumberInput(input, 'height') !== undefined + ? { height: optionalNumberInput(input, 'height') } + : {}), + }), + }, + { + name: 'get_presentation_state', + title: 'Inspect presentation state', + description: + 'Return bounded project and slide state. Detailed element data is limited to five slides per call.', + annotations: readerAnnotations, inputSchema: { type: 'object', - properties: { name: { type: 'string' } }, + additionalProperties: false, + properties: { + detail: { type: 'string', enum: ['summary', 'elements'] }, + slideNumbers: { type: 'array', items: { type: 'integer', minimum: 1 }, maxItems: 5 }, + cursor: { type: 'integer', minimum: 0 }, + elementCursor: { type: 'integer', minimum: 0 }, + elementLimit: { type: 'integer', minimum: 1, maximum: 50 }, + }, }, - execute: (input) => { - const name = stringInput(input, 'name'); - return this.controller.createProject(name ? { name } : {}); + execute: (input) => + this.controller.getPresentationState({ + detail: stringInput(input, 'detail') === 'elements' ? 'elements' : 'summary', + ...(optionalNumberArrayInput(input, 'slideNumbers') + ? { slideNumbers: optionalNumberArrayInput(input, 'slideNumbers') } + : {}), + ...(optionalNumberInput(input, 'cursor') !== undefined + ? { cursor: optionalNumberInput(input, 'cursor') } + : {}), + ...(optionalNumberInput(input, 'elementCursor') !== undefined + ? { elementCursor: optionalNumberInput(input, 'elementCursor') } + : {}), + ...(optionalNumberInput(input, 'elementLimit') !== undefined + ? { elementLimit: optionalNumberInput(input, 'elementLimit') } + : {}), + }), + }, + { + name: 'import_powerpoint_from_url', + title: 'Import PowerPoint from URL', + description: + 'Import a PPTX from an authorized HTTP(S), presigned object-storage, or localhost URL using the native PowerPoint workflow.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['url'], + properties: { + url: { type: 'string', minLength: 1, maxLength: 8000 }, + fileName: { type: 'string', minLength: 1, maxLength: 500 }, + }, }, + execute: (input) => + this.controller.importPowerPointFromUrl({ + url: stringInput(input, 'url'), + ...(optionalStringInput(input, 'fileName') + ? { fileName: optionalStringInput(input, 'fileName') } + : {}), + }), }, { - name: 'generate_slides', - description: `Generate slide content on the active page from a prompt. Good prompt examples:\n${promptExamplesList(promptRecipes.slidePromptExamples)}`, + name: 'translate_deck_and_notes', + title: 'Translate deck and notes', + description: + 'Translate all visible text, speaker notes, and existing semantic descriptions in the presentation.', + annotations: operationAnnotations, inputSchema: { type: 'object', - required: ['prompt'], - properties: { prompt: { type: 'string' } }, + additionalProperties: false, + required: ['targetLanguage'], + properties: { + targetLanguage: { type: 'string', minLength: 1, maxLength: 100 }, + sourceLanguage: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + execute: (input) => + this.controller.translateDeckAndNotes({ + targetLanguage: stringInput(input, 'targetLanguage'), + ...(optionalStringInput(input, 'sourceLanguage') + ? { sourceLanguage: optionalStringInput(input, 'sourceLanguage') } + : {}), + }), + }, + { + name: 'generate_deck_detailed_description', + title: 'Generate detailed deck descriptions', + description: + 'Generate fresh hidden semantic descriptions from structured slide content for attendee AI grounding.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + slideNumbers: { + type: 'array', + maxItems: 100, + uniqueItems: true, + items: { type: 'integer', minimum: 1 }, + }, + language: { type: 'string', minLength: 1, maxLength: 100 }, + force: { type: 'boolean' }, + }, }, - execute: (input) => this.controller.generateSlides({ prompt: stringInput(input, 'prompt') }), + execute: (input) => + this.controller.generateDeckDetailedDescription({ + ...(optionalNumberArrayInput(input, 'slideNumbers') + ? { slideNumbers: optionalNumberArrayInput(input, 'slideNumbers') } + : {}), + ...(optionalStringInput(input, 'language') + ? { language: optionalStringInput(input, 'language') } + : {}), + ...(optionalBooleanInput(input, 'force') !== undefined + ? { force: optionalBooleanInput(input, 'force') } + : {}), + }), + }, + { + name: 'list_authoring_catalog', + title: 'List authoring catalog', + description: + 'List usable fonts or animations compatible with a text, image, GIF, video, or shape element.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['kind'], + allOf: [ + { + if: { properties: { kind: { const: 'animations' } } }, + then: { required: ['elementType'] }, + }, + ], + properties: { + kind: { type: 'string', enum: ['fonts', 'animations'] }, + elementType: { type: 'string', enum: ['text', 'image', 'gif', 'video', 'shape'] }, + }, + }, + execute: (input) => + this.controller.listAuthoringCatalog({ + kind: stringInput(input, 'kind') as 'fonts' | 'animations', + ...(optionalStringInput(input, 'elementType') + ? { + elementType: optionalStringInput(input, 'elementType') as + | 'text' + | 'image' + | 'gif' + | 'video' + | 'shape', + } + : {}), + }), + }, + { + name: 'upsert_slide_content', + title: 'Upsert exact slide content', + description: + 'Atomically merge or replace exact slide primitives using stable IDs, frames, z-indexes, styles, media, and animations.', + annotations: operationAnnotations, + inputSchema: slideUpsertInputSchema, + execute: (input) => + this.controller.upsertSlideContent(input as unknown as SlideUpsertBatch), }, { name: 'generate_image', - description: `Generate an image for the active slide. If an image is selected, replace it; otherwise insert a fitted image. Good prompt examples:\n${promptExamplesList(promptRecipes.imagePromptExamples)}`, + title: 'Generate image asset', + description: `Generate an image asset without placing it. Use its assetId in upsert_slide_content. Example: ${promptRecipes.imagePromptExamples[0]}`, + annotations: operationAnnotations, inputSchema: { type: 'object', + additionalProperties: false, required: ['prompt'], properties: { - height: { type: 'number' }, - prompt: { type: 'string' }, - seed: { type: 'number' }, - steps: { type: 'number' }, - width: { type: 'number' }, + prompt: { type: 'string', minLength: 1, maxLength: 10000 }, + width: { type: 'integer', minimum: 64, maximum: 4096 }, + height: { type: 'integer', minimum: 64, maximum: 4096 }, + seed: { type: 'integer' }, + steps: { type: 'integer', minimum: 1, maximum: 100 }, + }, + }, + execute: (input) => + this.controller.generateImage({ + prompt: stringInput(input, 'prompt'), + ...(optionalNumberInput(input, 'width') !== undefined + ? { width: optionalNumberInput(input, 'width') } + : {}), + ...(optionalNumberInput(input, 'height') !== undefined + ? { height: optionalNumberInput(input, 'height') } + : {}), + ...(optionalNumberInput(input, 'seed') !== undefined + ? { seed: optionalNumberInput(input, 'seed') } + : {}), + ...(optionalNumberInput(input, 'steps') !== undefined + ? { steps: optionalNumberInput(input, 'steps') } + : {}), + }), + }, + { + name: 'get_slide_preview', + title: 'Focus slide preview', + description: + 'Select and fit a slide in the visible editor for browser-vision inspection and return its render hash.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['slideNumber'], + properties: { slideNumber: { type: 'integer', minimum: 1 } }, + }, + execute: (input) => + this.controller.getSlidePreview({ + slideNumber: optionalNumberInput(input, 'slideNumber') ?? 0, + }), + }, + { + name: 'get_ai_model_status', + title: 'Inspect AI model status', + description: + 'Report browser compatibility, selected providers, model readiness, sizes, progress, and errors.', + annotations: readerAnnotations, + inputSchema: emptyObjectSchema, + execute: () => this.controller.getAiModelStatus(), + }, + { + name: 'prepare_ai_models', + title: 'Prepare AI models', + description: + 'Download required AI models or explicit model IDs and expose progress through get_operation_status.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + modelIds: { + type: 'array', + maxItems: 50, + uniqueItems: true, + items: { type: 'string', minLength: 1, maxLength: 500 }, + }, }, }, - execute: (input) => this.controller.generateImage(imageInput(input)), + execute: (input) => + this.controller.prepareAiModels({ + ...(optionalStringArrayInput(input, 'modelIds') + ? { modelIds: optionalStringArrayInput(input, 'modelIds') } + : {}), + }), }, { - name: 'translate_text', - description: 'Translate visible text in the active LocalStudio.dev project. Scope must be selection, slide, or deck.', + name: 'search_media', + title: 'Search stock media', + description: + 'Search configured Unsplash images or Giphy GIFs and return bounded media references with attribution.', + annotations: readerAnnotations, inputSchema: { type: 'object', - required: ['scope', 'targetLanguage'], + additionalProperties: false, + required: ['kind', 'term'], properties: { - pageId: { type: 'string' }, - scope: { type: 'string', enum: ['selection', 'slide', 'deck'] }, - targetLanguage: { type: 'string' }, + kind: { type: 'string', enum: ['image', 'gif'] }, + term: { type: 'string', minLength: 1, maxLength: 500 }, + limit: { type: 'integer', minimum: 1, maximum: 30 }, }, }, - execute: (input) => this.controller.translateText(translateInput(input)), + execute: (input) => + this.controller.searchMedia({ + kind: stringInput(input, 'kind') as 'image' | 'gif', + term: stringInput(input, 'term'), + ...(optionalNumberInput(input, 'limit') !== undefined + ? { limit: optionalNumberInput(input, 'limit') } + : {}), + }), }, { - name: 'get_project_snapshot', - description: 'Return a compact JSON snapshot of the active LocalStudio.dev project without large image payloads.', + name: 'export_presentation', + title: 'Export presentation', + description: + 'Export as PPTX, PDF, PNG archive, or JPEG archive using the visible editor render path.', + annotations: operationAnnotations, inputSchema: { type: 'object', - properties: {}, + additionalProperties: false, + required: ['format'], + properties: { + format: { type: 'string', enum: ['pptx', 'pdf', 'png', 'jpeg'] }, + slideRange: { type: 'string', enum: ['all', 'current'] }, + includeAnimationFrames: { type: 'boolean' }, + }, }, - execute: () => this.controller.getProjectSnapshot(), + execute: (input) => + this.controller.exportPresentation({ + format: stringInput(input, 'format') as 'pptx' | 'pdf' | 'png' | 'jpeg', + ...(optionalStringInput(input, 'slideRange') + ? { slideRange: optionalStringInput(input, 'slideRange') as 'all' | 'current' } + : {}), + ...(optionalBooleanInput(input, 'includeAnimationFrames') !== undefined + ? { includeAnimationFrames: optionalBooleanInput(input, 'includeAnimationFrames') } + : {}), + }), + }, + { + name: 'publish_presentation', + title: 'Publish presentation', + description: + 'Publish the exact current revision, fonts, descriptions, transcript, and authorized recording media.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { shareId: { type: 'string', minLength: 1, maxLength: 500 } }, + }, + execute: (input) => + this.controller.publishPresentation({ + ...(optionalStringInput(input, 'shareId') + ? { shareId: optionalStringInput(input, 'shareId') } + : {}), + }), + }, + { + name: 'get_operation_status', + title: 'Get authoring operation status', + description: + 'Read queued, running, completed, or failed progress and the typed final result.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['operationId'], + properties: { + operationId: { type: 'string', minLength: 1, maxLength: 500 }, + waitForChangeMs: { type: 'integer', minimum: 0, maximum: 5000 }, + }, + }, + execute: (input) => + this.controller.getOperationStatus({ + operationId: stringInput(input, 'operationId'), + ...(optionalNumberInput(input, 'waitForChangeMs') !== undefined + ? { waitForChangeMs: optionalNumberInput(input, 'waitForChangeMs') } + : {}), + }), }, ]; } @@ -153,22 +455,21 @@ export class WebMcpToolAdapter { const cleanups: Array<() => void> = []; if (modelContext.registerTools) { try { - collectCleanup(cleanups, modelContext.registerTools(tools)); + const cleanup = modelContext.registerTools(tools); + if (isCleanupCallback(cleanup)) cleanups.push(cleanup); } catch (error) { if (!isDuplicateToolNameError(error)) throw error; } } else { tools.forEach((tool) => { try { - collectCleanup(cleanups, modelContext.registerTool?.(tool)); + const cleanup = modelContext.registerTool?.(tool); + if (isCleanupCallback(cleanup)) cleanups.push(cleanup); } catch (error) { if (!isDuplicateToolNameError(error)) throw error; } }); } - - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; + return () => cleanups.forEach((cleanup) => cleanup()); } } diff --git a/apps/editor/src/ui/editor/shell/EditorShell.tsx b/apps/editor/src/ui/editor/shell/EditorShell.tsx index 50ee5c91..72cf5ce1 100644 --- a/apps/editor/src/ui/editor/shell/EditorShell.tsx +++ b/apps/editor/src/ui/editor/shell/EditorShell.tsx @@ -7,8 +7,8 @@ import type { ProjectDocument, TranscriptRecording } from '../../../domain/docum import { pageVisibility } from '../../../domain/documents/pageVisibility'; import type { ShareMetadata, SharePublishProgress } from '../../../services/contracts/interfaces'; import { analyticsModelProperties } from '../../../services/analytics/analyticsModelProperties'; -import { editorAutomationController } from '../../../services/automation/editorAutomationController'; -import type { EditorAutomationDelegate } from '../../../services/automation/editorAutomationController'; +import { authoringAutomationController } from '../../../services/automation/authoringAutomationController'; +import { createAuthoringAutomationDelegate } from '../../../services/automation/createAuthoringAutomationDelegate'; import { imageGenerationModel } from '../../../services/image-generation/imageGenerationModel'; import { WebMcpToolAdapter, @@ -118,6 +118,7 @@ export function EditorShell({ services }: EditorShellProps) { function EditorDesktopShell({ services }: EditorShellProps) { const vm = useEditorViewModel(services); + const authoringVmRef = useRef(vm); const presenterTranscriptionLanguage = vm.translationLanguageOptions.find( (language) => language.code === vm.translationTargetLanguage, @@ -1210,6 +1211,8 @@ function EditorDesktopShell({ services }: EditorShellProps) { automationDelegateRef.current = vm.automation; }, [vm.automation]); + authoringVmRef.current = vm; + useEffect(() => { prepareProjectFontsForPublicShareRef.current = vm.prepareProjectFontsForPublicShare; }); @@ -1495,15 +1498,15 @@ function EditorDesktopShell({ services }: EditorShellProps) { useEffect(() => { if (!editorShellBrowserUtils.isWebMcpProtocolEnabled()) return undefined; - const delegate: EditorAutomationDelegate = { - createProject: (input) => automationDelegateRef.current.createProject(input), - generateSlides: (input) => automationDelegateRef.current.generateSlides(input), - generateImage: (input) => automationDelegateRef.current.generateImage(input), - translateText: (input) => automationDelegateRef.current.translateText(input), - getState: () => automationDelegateRef.current.getState(), - }; + const delegate = createAuthoringAutomationDelegate({ + fontImportService: services.fontImportService, + getProject: () => automationDelegateRef.current.getState().project, + replaceProject: (project) => authoringVmRef.current.replaceProjectForAutomation(project), + applyProject: (project, activePageId) => + authoringVmRef.current.applyProjectForAutomation(project, activePageId), + }); const adapter = new WebMcpToolAdapter( - new editorAutomationController.EditorAutomationController(delegate), + new authoringAutomationController.AuthoringAutomationController(delegate), ); const demoWindow = window as WebMcpDemoWindow; const modelContext = editorShellBrowserUtils.getWebMcpModelContext(); @@ -1514,7 +1517,7 @@ function EditorDesktopShell({ services }: EditorShellProps) { unregister?.(); delete demoWindow.localStudioWebMcpTools; }; - }, []); + }, [services.fontImportService]); useEffect( () => () => { diff --git a/apps/editor/src/ui/editor/state/useEditorViewModel.ts b/apps/editor/src/ui/editor/state/useEditorViewModel.ts index 4ad93a41..d0c36220 100644 --- a/apps/editor/src/ui/editor/state/useEditorViewModel.ts +++ b/apps/editor/src/ui/editor/state/useEditorViewModel.ts @@ -724,6 +724,13 @@ export function useEditorViewModel(services: AppServices) { setLastEditedAt(nextProject.updatedAt); } + function applyProjectForAutomation(nextProject: ProjectDocument, nextActivePageId?: string) { + commitProject(() => nextProject, { + ...(nextActivePageId ? { activePageId: nextActivePageId } : {}), + selectedElementIds: [], + }); + } + async function downloadRequiredModels() { setModelStates((currentStates) => currentStates.map((state) => @@ -3596,6 +3603,8 @@ export function useEditorViewModel(services: AppServices) { return { project: previewProject ?? project, automation, + applyProjectForAutomation, + replaceProjectForAutomation, activePageId, activePageFocusKey, zoomPercent, diff --git a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx index fe8db747..8d004e70 100644 --- a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx +++ b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx @@ -1,7 +1,5 @@ -import { Bot, FileJson, ImagePlus, Languages, Play, Radar, SendHorizontal } from 'lucide-react'; +import { Bot, FileJson, Play, Radar, SendHorizontal } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; -import { promptRecipes } from '../editor/prompting/promptRecipes'; -import { TRANSLATION_LANGUAGE_OPTIONS } from '../editor/translation/translationLanguages'; interface WebMcpToolLike { call?: (input: Record) => unknown; @@ -24,34 +22,59 @@ interface DemoStep { const demoSteps: DemoStep[] = [ { - label: 'Create project', - toolName: 'create_project', + label: 'Create presentation', + toolName: 'create_presentation', input: { name: 'WebMCP Demo Deck' }, }, { - label: 'Generate slide', - toolName: 'generate_slides', - input: { prompt: promptRecipes.slidePromptExamples[1] }, - }, - { - label: 'Generate image', - toolName: 'generate_image', + label: 'Upsert slide', + toolName: 'upsert_slide_content', input: { - prompt: promptRecipes.imagePromptExamples[1], - width: 512, - height: 512, - steps: 4, + requestId: 'webmcp-showcase-slide-1', + slideNumber: 1, + mode: 'replace', + slide: { + name: 'Agent-native presentations', + background: { type: 'color', color: '#050D10' }, + }, + elements: [ + { + elementId: 'showcase-title', + type: 'text', + frame: { x: 180, y: 260, width: 1560, height: 220 }, + zIndex: 1, + content: { text: 'Presentations become agent-native' }, + style: { + fontFamily: 'Orbitron', + fontSize: 88, + fontWeight: 800, + color: '#37FD76', + align: 'center', + }, + }, + { + elementId: 'showcase-body', + type: 'text', + frame: { x: 360, y: 560, width: 1200, height: 120 }, + zIndex: 2, + content: { + text: 'Create, inspect, localize, export, and publish through browser-native tools.', + }, + style: { + fontFamily: 'Open Sans', + fontSize: 42, + fontWeight: 600, + color: '#FFFFFF', + align: 'center', + }, + }, + ], }, }, { - label: 'Translate deck', - toolName: 'translate_text', - input: { scope: 'deck', targetLanguage: 'pt' }, - }, - { - label: 'Read snapshot', - toolName: 'get_project_snapshot', - input: {}, + label: 'Read presentation state', + toolName: 'get_presentation_state', + input: { detail: 'elements', slideNumbers: [1] }, }, ]; @@ -61,10 +84,13 @@ function getBrowserModelContext() { } function isWebMcpToolLikeArray(value: unknown): value is WebMcpToolLike[] { - return Array.isArray(value) && value.every((item) => { - if (!item || typeof item !== 'object') return false; - return typeof (item as { name?: unknown }).name === 'string'; - }); + return ( + Array.isArray(value) && + value.every((item) => { + if (!item || typeof item !== 'object') return false; + return typeof (item as { name?: unknown }).name === 'string'; + }) + ); } function getLocalDemoTools(iframe: HTMLIFrameElement) { @@ -79,7 +105,8 @@ function callTool( input: Record, modelContext = getBrowserModelContext(), ) { - if (modelContext?.executeTool) return Promise.resolve(modelContext.executeTool(tool, JSON.stringify(input))); + if (modelContext?.executeTool) + return Promise.resolve(modelContext.executeTool(tool, JSON.stringify(input))); const callable = tool.call ?? tool.execute ?? tool.invoke; if (!callable) throw new Error(`${tool.name} is not callable in this WebMCP runtime.`); return Promise.resolve(callable(input)); @@ -90,20 +117,20 @@ function formatPayload(value: unknown) { } function getDefaultCommandValue(step: DemoStep) { - const primaryValue = step.input.name ?? step.input.prompt ?? step.input.targetLanguage; + const primaryValue = step.input.name; return typeof primaryValue === 'string' ? primaryValue : formatPayload(step.input); } function getCommandInput(step: DemoStep, value: string) { - if (step.toolName === 'create_project') return { name: value }; - if (step.toolName === 'generate_slides') return { prompt: value }; - if (step.toolName === 'generate_image') return { ...step.input, prompt: value }; - if (step.toolName === 'translate_text') return { ...step.input, targetLanguage: value }; + if (step.toolName === 'create_presentation') return { name: value }; + if (step.toolName === 'upsert_slide_content') { + return JSON.parse(value) as Record; + } return step.input; } function hasCommandInput(step: DemoStep) { - return step.toolName !== 'get_project_snapshot'; + return step.toolName !== 'get_presentation_state'; } export function WebMcpShowcasePage() { @@ -154,7 +181,9 @@ export function WebMcpShowcasePage() { ? await modelContext.getTools({ fromOrigins: [iframeOrigin] }) : fallbackTools; if (!discoveredTools) { - setStatus('No WebMCP runtime or same-origin demo tools found. Wait for the editor frame, then try again.'); + setStatus( + 'No WebMCP runtime or same-origin demo tools found. Wait for the editor frame, then try again.', + ); setTools([]); return; } @@ -164,7 +193,11 @@ export function WebMcpShowcasePage() { ? `Discovered ${discoveredTools.length} tools through WebMCP.` : `Discovered ${discoveredTools.length} tools through the local demo bridge.`, ); - setLastResult(formatPayload(discoveredTools.map((tool) => ({ name: tool.name, description: tool.description })))); + setLastResult( + formatPayload( + discoveredTools.map((tool) => ({ name: tool.name, description: tool.description })), + ), + ); } async function runStep(step: DemoStep, input = step.input) { @@ -200,8 +233,8 @@ export function WebMcpShowcasePage() {

Browser agent surface

WebMCP showcase

- A host page discovers semantic tools from the editor iframe and calls the same automation layer - used by the LocalStudio interface. + A host page discovers semantic tools from the editor iframe and calls the same + automation layer used by the LocalStudio interface.

@@ -251,7 +284,9 @@ export function WebMcpShowcasePage() { 'webmcp-step-button', activeStepName === step.toolName ? 'webmcp-step-button-active' : '', focusedStepName === step.toolName ? 'webmcp-step-button-focused' : '', - ].filter(Boolean).join(' ')} + ] + .filter(Boolean) + .join(' ')} disabled={isRunning} type="button" onClick={() => { @@ -259,10 +294,8 @@ export function WebMcpShowcasePage() { }} > {index + 1} - {step.toolName === 'generate_image' ? : null} - {step.toolName === 'translate_text' ? : null} - {step.toolName === 'get_project_snapshot' ? : null} - {step.toolName === 'generate_slides' || step.toolName === 'create_project' ? : null} + {step.toolName === 'get_presentation_state' ? : null} + {step.toolName !== 'get_presentation_state' ? : null} {step.label} {activeStepName === step.toolName && hasCommandInput(step) ? ( @@ -273,35 +306,16 @@ export function WebMcpShowcasePage() { void runStep(step, getCommandInput(step, commandValues[step.toolName] ?? '')); }} > - {step.toolName === 'translate_text' ? ( - - ) : ( - { - setCommandValues((current) => ({ - ...current, - [step.toolName]: event.target.value, - })); - }} - /> - )} + { + setCommandValues((current) => ({ + ...current, + [step.toolName]: event.target.value, + })); + }} + /> diff --git a/apps/editor/tests/unit/app/App.test.tsx b/apps/editor/tests/unit/app/App.test.tsx index a17ba92d..71a77974 100644 --- a/apps/editor/tests/unit/app/App.test.tsx +++ b/apps/editor/tests/unit/app/App.test.tsx @@ -1,8 +1,7 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { App } from '../../../src/App'; import { sampleProject } from '../../../src/domain/projects/sampleProject'; -import { TRANSLATION_LANGUAGE_OPTIONS } from '../../../src/ui/editor/translation/translationLanguages'; const originalMatchMedia = window.matchMedia; @@ -159,11 +158,17 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/s/${shareId}?src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/s/${shareId}?src=${encodeURIComponent(sourceUrl)}`, + ); render(); - expect(await screen.findByLabelText('Public presentation')).toHaveClass('public-deck-viewer-present'); + expect(await screen.findByLabelText('Public presentation')).toHaveClass( + 'public-deck-viewer-present', + ); expect(screen.getByText('1 / 1')).toBeInTheDocument(); }); @@ -187,11 +192,17 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/?share=${shareId}&src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/?share=${shareId}&src=${encodeURIComponent(sourceUrl)}`, + ); render(); - expect(await screen.findByLabelText('Public presentation')).toHaveClass('public-deck-viewer-present'); + expect(await screen.findByLabelText('Public presentation')).toHaveClass( + 'public-deck-viewer-present', + ); expect(screen.getByText('1 / 1')).toBeInTheDocument(); }); @@ -215,7 +226,11 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/embed/${shareId}?src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/embed/${shareId}?src=${encodeURIComponent(sourceUrl)}`, + ); render(); @@ -243,7 +258,11 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/?embed=${shareId}&src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/?embed=${shareId}&src=${encodeURIComponent(sourceUrl)}`, + ); render(); @@ -263,79 +282,86 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Create project' })); + fireEvent.click(screen.getByRole('button', { name: 'Create presentation' })); - expect(screen.getByLabelText('Create project command input')).toHaveValue('WebMCP Demo Deck'); - expect(screen.getByRole('button', { name: 'Send Create project' })).toBeInTheDocument(); + expect(screen.getByLabelText('Create presentation command input')).toHaveValue( + 'WebMCP Demo Deck', + ); + expect(screen.getByRole('button', { name: 'Send Create presentation' })).toBeInTheDocument(); }); - it('shows the AI tools translation options for the WebMCP translate step', () => { + it('shows the complete JSON batch for the WebMCP upsert step', () => { window.history.replaceState({}, '', '/webmcp'); render(); - fireEvent.click(screen.getByRole('button', { name: 'Translate deck' })); + fireEvent.click(screen.getByRole('button', { name: 'Upsert slide' })); - const languageSelect = screen.getByLabelText('Translate deck command input'); - const options = within(languageSelect).getAllByRole('option'); - expect(options).toHaveLength(TRANSLATION_LANGUAGE_OPTIONS.length); - expect(options.map((option) => option.value)).toEqual( - TRANSLATION_LANGUAGE_OPTIONS.map((language) => language.code), - ); - expect(screen.getByRole('option', { name: 'Hebrew (iw) 🇮🇱' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'Chinese (Traditional) (zh-Hant) 🇹🇼' })).toBeInTheDocument(); + const batchInput = screen.getByLabelText('Upsert slide command input'); + expect(batchInput.value).toContain('"requestId"'); + expect(batchInput.value).toContain('"elements"'); }); it('runs the WebMCP snapshot step without showing a command input', async () => { - const executeSnapshot = vi.fn().mockResolvedValue({ project: { name: 'Demo' } }); + const executeState = vi.fn().mockResolvedValue({ projectId: 'project-1', name: 'Demo' }); window.history.replaceState({}, '', '/webmcp'); Object.defineProperty(document, 'modelContext', { configurable: true, value: { - getTools: vi.fn().mockResolvedValue([ - { name: 'get_project_snapshot', description: 'Read snapshot', execute: executeSnapshot }, - ]), + getTools: vi + .fn() + .mockResolvedValue([ + { name: 'get_presentation_state', description: 'Read state', execute: executeState }, + ]), }, }); render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'get_project_snapshot' })); - fireEvent.click(screen.getByRole('button', { name: 'Read snapshot' })); + fireEvent.click(await screen.findByRole('button', { name: 'get_presentation_state' })); + fireEvent.click(screen.getByRole('button', { name: 'Read presentation state' })); await waitFor(() => { - expect(executeSnapshot).toHaveBeenCalledWith({}); + expect(executeState).toHaveBeenCalledWith({ detail: 'elements', slideNumbers: [1] }); }); - expect(screen.queryByLabelText('Read snapshot command input')).not.toBeInTheDocument(); - expect(screen.getByText('Read snapshot completed.')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Read presentation state command input'), + ).not.toBeInTheDocument(); + expect(screen.getByText('Read presentation state completed.')).toBeInTheDocument(); }); it('runs WebMCP descriptor tools through the browser runtime executor', async () => { - const createProjectTool = { name: 'create_project', description: 'Create project' }; + const createPresentationTool = { + name: 'create_presentation', + description: 'Create presentation', + }; const executeTool = vi.fn().mockResolvedValue({ ok: true, data: { name: 'Runtime Deck' } }); window.history.replaceState({}, '', '/webmcp'); Object.defineProperty(document, 'modelContext', { configurable: true, value: { executeTool, - getTools: vi.fn().mockResolvedValue([createProjectTool]), + getTools: vi.fn().mockResolvedValue([createPresentationTool]), }, }); render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'Create project' })); - fireEvent.change(screen.getByLabelText('Create project command input'), { + fireEvent.click(await screen.findByRole('button', { name: 'Create presentation' })); + fireEvent.change(screen.getByLabelText('Create presentation command input'), { target: { value: 'Runtime Deck' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Send Create project' })); + fireEvent.click(screen.getByRole('button', { name: 'Send Create presentation' })); await waitFor(() => { - expect(executeTool).toHaveBeenCalledWith(createProjectTool, JSON.stringify({ name: 'Runtime Deck' })); + expect(executeTool).toHaveBeenCalledWith( + createPresentationTool, + JSON.stringify({ name: 'Runtime Deck' }), + ); }); - expect(screen.getByText('Create project completed.')).toBeInTheDocument(); + expect(screen.getByText('Create presentation completed.')).toBeInTheDocument(); }); it('focuses the matching workflow step when a discovered tool is selected', async () => { @@ -344,8 +370,8 @@ describe('App', () => { configurable: true, value: { getTools: vi.fn().mockResolvedValue([ - { name: 'create_project', description: 'Create project', execute: vi.fn() }, - { name: 'generate_slides', description: 'Generate slides', execute: vi.fn() }, + { name: 'create_presentation', description: 'Create presentation', execute: vi.fn() }, + { name: 'upsert_slide_content', description: 'Upsert slide', execute: vi.fn() }, ]), }, }); @@ -353,9 +379,9 @@ describe('App', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'generate_slides' })); + fireEvent.click(await screen.findByRole('button', { name: 'upsert_slide_content' })); - const stepButton = screen.getByRole('button', { name: 'Generate slide' }); + const stepButton = screen.getByRole('button', { name: 'Upsert slide' }); expect(stepButton).toHaveFocus(); expect(stepButton).toHaveClass('webmcp-step-button-focused'); }); diff --git a/apps/editor/tests/unit/services/authoringAutomationController.test.ts b/apps/editor/tests/unit/services/authoringAutomationController.test.ts new file mode 100644 index 00000000..c21671c6 --- /dev/null +++ b/apps/editor/tests/unit/services/authoringAutomationController.test.ts @@ -0,0 +1,207 @@ +import { authoringAutomationController } from '../../../src/services/automation/authoringAutomationController'; +import type { AuthoringAutomationDelegate } from '../../../src/services/automation/authoringAutomationController'; +import type { SlideUpsertBatch } from '../../../src/services/automation/slideUpsertService'; + +function createDelegate( + overrides: Partial = {}, +): AuthoringAutomationDelegate { + return { + createPresentation: () => ({}), + getPresentationState: () => ({}), + upsertSlideContent: () => Promise.reject(new Error('unused')), + ...overrides, + }; +} + +describe('authoringAutomationController', () => { + it('reports asynchronous operation progress and completion', async () => { + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ + importPowerPointFromUrl: (_input, report) => { + report({ stage: 'importing-powerpoint', progress: 60, current: 2, total: 3 }); + return Promise.resolve({ pageCount: 3 }); + }, + }), + ); + + const started = controller.importPowerPointFromUrl({ url: 'https://example.test/deck.pptx' }); + expect(started).toMatchObject({ ok: true, data: { status: 'queued' } }); + const operationId = started.ok ? started.data.operationId : ''; + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect(controller.getOperationStatus({ operationId })).resolves.toMatchObject({ + ok: true, + data: { + state: 'completed', + progress: 100, + percentage: 100, + revision: 3, + result: { pageCount: 3 }, + }, + }); + }); + + it('returns the original result for an idempotent upsert replay', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 1, + updatedElements: 0, + deletedElements: 0, + elementCount: 1, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input = { + requestId: 'retry-safe', + slideNumber: 1, + mode: 'merge' as const, + elements: [], + }; + + await expect( + controller.upsertSlideContent({ + elements: input.elements, + mode: input.mode, + slideNumber: input.slideNumber, + requestId: input.requestId, + }), + ).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: true }, + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent retries for the same request ID', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input: SlideUpsertBatch = { + requestId: 'concurrent', + slideNumber: 1, + mode: 'merge', + elements: [], + }; + + const [first, retry] = await Promise.all([ + controller.upsertSlideContent(input), + controller.upsertSlideContent(input), + ]); + + expect(first).toMatchObject({ ok: true, data: { idempotentReplay: false } }); + expect(retry).toMatchObject({ ok: true, data: { idempotentReplay: true } }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('scopes idempotent request IDs to the current presentation', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input: SlideUpsertBatch = { + requestId: 'reusable-after-create', + slideNumber: 1, + mode: 'merge', + elements: [], + }; + + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + await controller.createPresentation({ name: 'A different deck' }); + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(2); + }); + + it('rejects a reused request ID when the batch body changes', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + + await controller.upsertSlideContent({ + requestId: 'conflict', + slideNumber: 1, + mode: 'merge', + elements: [], + }); + await expect( + controller.upsertSlideContent({ + requestId: 'conflict', + slideNumber: 1, + mode: 'replace', + elements: [], + }), + ).resolves.toEqual({ + ok: false, + errorCode: 'request_id_conflict', + message: 'requestId conflict was already used with a different batch.', + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('returns an explicit pending result for reserved catalog capabilities', () => { + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate(), + ); + + expect(controller.publishPresentation({})).toEqual({ + ok: false, + errorCode: 'capability_pending', + message: + 'publish_presentation is reserved in the authoring catalog and will be implemented in #177.', + }); + }); +}); diff --git a/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts new file mode 100644 index 00000000..6d0442cf --- /dev/null +++ b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts @@ -0,0 +1,184 @@ +import { sampleProject } from '../../../src/domain/projects/sampleProject'; +import { createAuthoringAutomationDelegate } from '../../../src/services/automation/createAuthoringAutomationDelegate'; +import type { + FontImportRequest, + FontImportService, +} from '../../../src/services/contracts/interfaces'; + +function createHarness() { + let project = sampleProject.createBlankProject(); + const resolveAndDownloadFonts = vi.fn((requests: FontImportRequest[]) => + Promise.resolve({ + fonts: Object.fromEntries( + requests.map((request) => [ + request.family, + { + id: `font-${request.family}`, + family: request.family, + source: 'google-fonts' as const, + requestedFamily: request.family, + fontStyle: request.fontStyle, + fontWeight: request.fontWeight, + mimeType: 'font/woff2' as const, + fileName: `${request.family}.woff2`, + storage: 'remote' as const, + sourceUrl: `https://fonts.example/${request.family}.woff2`, + }, + ]), + ), + resolutions: requests.map((request) => ({ + requestedFamily: request.family, + family: request.family, + fontStyle: request.fontStyle, + fontWeight: request.fontWeight, + status: 'downloaded-exact' as const, + })), + warnings: [], + }), + ); + const fontImportService: FontImportService = { + listDownloadableFonts: vi.fn(() => []), + resolveAndDownloadFonts, + loadProjectFonts: vi.fn(() => Promise.resolve()), + }; + const delegate = createAuthoringAutomationDelegate({ + fontImportService, + getProject: () => project, + replaceProject: (nextProject) => { + project = nextProject; + }, + applyProject: (nextProject) => { + project = nextProject; + }, + }); + return { delegate, resolveAndDownloadFonts, getProject: () => project }; +} + +describe('createAuthoringAutomationDelegate', () => { + it('creates a named 1920x1080 presentation and returns bounded state', async () => { + const harness = createHarness(); + + expect(await harness.delegate.createPresentation({ name: 'Agent Deck' })).toMatchObject({ + name: 'Agent Deck', + width: 1920, + height: 1080, + slideId: 'page-1', + }); + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + name: 'Agent Deck', + pageCount: 1, + slides: [{ slideNumber: 1, descriptionFreshness: 'missing' }], + }); + expect(() => harness.delegate.createPresentation({ width: 0 })).toThrow( + 'dimensions must be positive', + ); + }); + + it('paginates detailed elements without returning an unbounded slide payload', async () => { + const harness = createHarness(); + const elements = ['one', 'two', 'three'].map((elementId, index) => ({ + elementId, + type: 'text' as const, + frame: { x: index * 100, y: 0, width: 90, height: 50 }, + zIndex: index, + content: { text: elementId }, + style: { fontFamily: 'Arial', fontSize: 20, fontWeight: 400, color: '#000000' }, + })); + await harness.delegate.upsertSlideContent({ + requestId: 'state-pagination', + slideNumber: 1, + mode: 'replace', + elements, + }); + + expect( + await harness.delegate.getPresentationState({ + detail: 'elements', + slideNumbers: [1], + elementLimit: 2, + }), + ).toMatchObject({ + slides: [{ elements: [{ id: 'one' }, { id: 'two' }], nextElementCursor: 2 }], + }); + }); + + it('reports a description as stale when its source revision no longer matches the slide', async () => { + const harness = createHarness(); + const initialState = (await harness.delegate.getPresentationState({ detail: 'summary' })) as { + slides: Array<{ revision: string }>; + }; + const page = harness.getProject().pages[0]; + if (!page) throw new Error('Expected a first page.'); + page.semanticDescription = { + text: 'An old description', + language: 'en', + generatedAt: '2026-08-26T00:00:00.000Z', + generator: 'test', + sourceRevision: initialState.slides[0]?.revision ?? '', + reviewed: false, + stale: false, + }; + + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + slides: [{ slideNumber: 1, descriptionFreshness: 'fresh' }], + }); + page.name = 'Changed after description generation'; + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + slides: [{ slideNumber: 1, descriptionFreshness: 'stale' }], + }); + }); + + it('downloads an available referenced font before applying the exact text element', async () => { + const harness = createHarness(); + + await harness.delegate.upsertSlideContent({ + requestId: 'font-upsert', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'title', + type: 'text', + frame: { x: 10, y: 20, width: 500, height: 100 }, + zIndex: 1, + content: { text: 'Exact slide' }, + style: { fontFamily: 'Roboto Slab', fontSize: 48, fontWeight: 700, color: '#123456' }, + }, + ], + }); + + expect(harness.resolveAndDownloadFonts).toHaveBeenCalledWith([ + { family: 'Roboto Slab', fontStyle: 'normal', fontWeight: 700 }, + ]); + expect(harness.getProject().elements.title).toMatchObject({ + text: 'Exact slide', + x: 10, + y: 20, + fontFamily: 'Roboto Slab', + fill: '#123456', + }); + }); + + it('rejects unsafe media URLs without changing the project', async () => { + const harness = createHarness(); + const original = harness.getProject(); + + await expect( + harness.delegate.upsertSlideContent({ + requestId: 'unsafe-media', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'image', + type: 'image', + frame: { x: 0, y: 0, width: 100, height: 100 }, + zIndex: 0, + content: { url: 'file:///tmp/private.png' }, + }, + ], + }), + ).rejects.toThrow('Only HTTP and HTTPS'); + expect(harness.getProject()).toBe(original); + }); +}); diff --git a/apps/editor/tests/unit/services/slideUpsertService.test.ts b/apps/editor/tests/unit/services/slideUpsertService.test.ts new file mode 100644 index 00000000..8a89f8ab --- /dev/null +++ b/apps/editor/tests/unit/services/slideUpsertService.test.ts @@ -0,0 +1,353 @@ +import { sampleProject } from '../../../src/domain/projects/sampleProject'; +import { slideUpsertService } from '../../../src/services/automation/slideUpsertService'; + +const textElement = { + elementId: 'title', + type: 'text' as const, + frame: { x: 100, y: 100, width: 900, height: 180 }, + zIndex: 2, + content: { text: 'Agent-native slides' }, + style: { + fontFamily: 'Orbitron', + fontSize: 72, + fontWeight: 800, + color: '#37FD76', + align: 'center' as const, + }, + animations: [{ effect: 'fade' as const, order: 1, durationMs: 500 }], +}; + +const options = { + createId: (prefix: string) => `${prefix}-test`, + resolveMedia: () => Promise.reject(new Error('Unexpected media resolution.')), +}; + +describe('slideUpsertService', () => { + it('replaces a slide atomically and preserves exact primitive values', async () => { + const project = sampleProject.createSampleProject(); + const result = await slideUpsertService.apply( + project, + { + requestId: 'replace-slide-1', + slideNumber: 1, + mode: 'replace', + slide: { name: 'WebMCP', background: { type: 'color', color: '#000000' } }, + elements: [textElement], + }, + options, + ); + + expect(result.project.pages[0]).toMatchObject({ + name: 'WebMCP', + elementIds: ['title'], + background: { type: 'color', color: '#000000' }, + }); + expect(result.project.elements.title).toMatchObject({ + id: 'title', + text: 'Agent-native slides', + x: 100, + y: 100, + width: 900, + height: 180, + fontFamily: 'Orbitron', + }); + expect(result.project.pages[0]?.animationBuilds).toHaveLength(1); + expect(result.deletedElements).toBe(3); + }); + + it('merges batches and performs explicit deletion', async () => { + const project = sampleProject.createBlankProject(); + const first = await slideUpsertService.apply( + project, + { + requestId: 'chunk-1', + slideNumber: 1, + mode: 'merge', + elements: [textElement], + }, + options, + ); + const second = await slideUpsertService.apply( + first.project, + { + requestId: 'chunk-2', + slideNumber: 1, + mode: 'merge', + elements: [ + { + ...textElement, + elementId: 'subtitle', + zIndex: 1, + content: { text: 'Bounded second batch' }, + animations: [], + }, + ], + deleteElementIds: ['title'], + }, + options, + ); + + expect(second.project.pages[0]?.elementIds).toEqual(['subtitle']); + expect(second.project.elements.title).toBeUndefined(); + expect(second.deletedElements).toBe(1); + }); + + it('orders multiple elements and animation builds by their explicit order', async () => { + const project = sampleProject.createBlankProject(); + const result = await slideUpsertService.apply( + project, + { + requestId: 'ordered-elements', + slideNumber: 1, + mode: 'replace', + elements: [ + { + ...textElement, + zIndex: 5, + animations: [{ effect: 'fade' as const, order: 4 }], + }, + { + ...textElement, + elementId: 'subtitle', + zIndex: 1, + animations: [{ effect: 'wipe' as const, order: 2 }], + }, + ], + }, + options, + ); + + expect(result.project.pages[0]?.elementIds).toEqual(['subtitle', 'title']); + expect(result.project.pages[0]?.animationBuilds).toMatchObject([ + { elementId: 'subtitle', effect: 'wipe', order: 2 }, + { elementId: 'title', effect: 'fade', order: 4 }, + ]); + }); + + it('counts only elements actually removed during a replace', async () => { + const project = sampleProject.createBlankProject(); + const first = await slideUpsertService.apply( + project, + { + requestId: 'initial-stable-element', + slideNumber: 1, + mode: 'replace', + elements: [textElement], + }, + options, + ); + + const result = await slideUpsertService.apply( + first.project, + { + requestId: 'replace-stable-element', + slideNumber: 1, + mode: 'replace', + elements: [{ ...textElement, content: { text: 'Updated title' } }], + }, + options, + ); + + expect(result).toMatchObject({ + createdElements: 0, + updatedElements: 1, + deletedElements: 0, + elementCount: 1, + }); + }); + + it('maps shape and media primitives with their exact native properties', async () => { + const project = sampleProject.createBlankProject(); + const resolveMedia = vi.fn( + ( + _content: { assetId?: string; url?: string; mediaRef?: string }, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ) => + Promise.resolve({ + id: `asset-${context.elementId}`, + type: context.type, + name: context.elementId, + mimeType: + context.type === 'video' + ? ('video/mp4' as const) + : context.type === 'gif' + ? ('image/gif' as const) + : ('image/png' as const), + objectUrl: `https://example.test/${context.elementId}`, + storage: 'remote' as const, + }), + ); + const frame = { x: 10, y: 20, width: 300, height: 200 }; + const result = await slideUpsertService.apply( + project, + { + requestId: 'native-primitives', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'shape', + type: 'shape', + frame, + zIndex: 0, + rotation: 15, + opacity: 0.7, + visible: false, + locked: true, + content: { shape: 'rounded-rect', fill: '#123456', stroke: '#abcdef', strokeWidth: 3 }, + }, + { + elementId: 'image', + type: 'image', + frame, + zIndex: 1, + content: { url: 'https://example.test/image.png' }, + crop: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, + flipX: true, + mask: 'ellipse', + }, + { + elementId: 'gif', + type: 'gif', + frame, + zIndex: 2, + content: { url: 'https://example.test/animation.gif' }, + playing: false, + }, + { + elementId: 'video', + type: 'video', + frame, + zIndex: 3, + content: { url: 'https://example.test/video.mp4' }, + playback: { + loop: true, + controls: false, + muted: true, + autoplayInPreview: true, + trimStartSeconds: 2, + trimEndSeconds: 8, + playAcrossSlides: true, + startOnClick: true, + volume: 0.4, + }, + }, + ], + }, + { ...options, resolveMedia }, + ); + + expect(result.project.elements.shape).toMatchObject({ + type: 'shape', + shape: 'rounded-rect', + fill: '#123456', + stroke: '#abcdef', + strokeWidth: 3, + rotation: 15, + opacity: 0.7, + visible: false, + locked: true, + }); + expect(result.project.elements.image).toMatchObject({ + type: 'image', + assetId: 'asset-image', + crop: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, + flipX: true, + mask: 'ellipse', + }); + expect(result.project.elements.gif).toMatchObject({ + type: 'gif', + assetId: 'asset-gif', + playing: false, + }); + expect(result.project.elements.video).toMatchObject({ + type: 'video', + assetId: 'asset-video', + loop: true, + controls: false, + muted: true, + autoplayInPreview: true, + trimStartSeconds: 2, + trimEndSeconds: 8, + playAcrossSlides: true, + startOnClick: true, + volume: 0.4, + }); + expect(resolveMedia).toHaveBeenCalledTimes(3); + }); + + it('rejects the complete batch before mutation when IDs collide across slides', async () => { + const project = sampleProject.createBlankProject(); + const withSecondSlide = await slideUpsertService.apply( + project, + { + requestId: 'new-slide', + slideNumber: 2, + mode: 'replace', + elements: [textElement], + }, + options, + ); + + await expect( + slideUpsertService.apply( + withSecondSlide.project, + { + requestId: 'collision', + slideNumber: 1, + mode: 'merge', + elements: [textElement], + }, + options, + ), + ).rejects.toThrow('belongs to another slide'); + expect(withSecondSlide.project.pages[0]?.elementIds).toEqual([]); + }); + + it('rejects attempts to create a slide after a positional gap', async () => { + const project = sampleProject.createBlankProject(); + + await expect( + slideUpsertService.apply( + project, + { + requestId: 'slide-gap', + slideNumber: 3, + mode: 'replace', + elements: [], + }, + options, + ), + ).rejects.toThrow('cannot contain gaps'); + expect(project.pages).toHaveLength(1); + }); + + it('validates explicit deletion before resolving any media', async () => { + const project = sampleProject.createBlankProject(); + const resolveMedia = vi.fn(() => Promise.reject(new Error('must not run'))); + + await expect( + slideUpsertService.apply( + project, + { + requestId: 'atomic-validation', + slideNumber: 1, + mode: 'merge', + deleteElementIds: ['missing'], + elements: [ + { + elementId: 'image', + type: 'image', + frame: { x: 0, y: 0, width: 100, height: 100 }, + zIndex: 0, + content: { url: 'https://example.test/image.png' }, + }, + ], + }, + { ...options, resolveMedia }, + ), + ).rejects.toThrow('does not belong'); + expect(resolveMedia).not.toHaveBeenCalled(); + expect(project.pages[0]?.elementIds).toEqual([]); + }); +}); diff --git a/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts b/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts index db7ddade..0bbab8bd 100644 --- a/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts +++ b/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts @@ -1,83 +1,118 @@ -import { promptRecipes } from '../../../src/ui/editor/prompting/promptRecipes'; +import { authoringAutomationController } from '../../../src/services/automation/authoringAutomationController'; +import type { AuthoringAutomationDelegate } from '../../../src/services/automation/authoringAutomationController'; import { WebMcpToolAdapter, type WebMcpTool } from '../../../src/services/webmcp/webMcpToolAdapter'; -describe('WebMcpToolAdapter', () => { - function createAdapter() { - return new WebMcpToolAdapter({ - createProject: vi.fn(), - generateSlides: vi.fn(), - generateImage: vi.fn(), - translateText: vi.fn(), - getProjectSnapshot: vi.fn(), - }); - } +const expectedToolNames = [ + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', + 'generate_image', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', +]; - it('registers discoverable WebMCP tools with prompt examples in metadata', () => { - const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); - const adapter = createAdapter(); +function createDelegate( + overrides: Partial = {}, +): AuthoringAutomationDelegate { + return { + createPresentation: vi.fn(() => ({ projectId: 'project-1', name: 'Untitled' })), + getPresentationState: vi.fn(() => ({ projectId: 'project-1', pageCount: 1 })), + importPowerPointFromUrl: vi.fn(() => Promise.resolve({ pageCount: 1 })), + translateDeckAndNotes: vi.fn(() => Promise.resolve({ translatedPageIds: [] })), + generateDeckDetailedDescription: vi.fn(() => Promise.resolve({ describedSlides: 1 })), + listAuthoringCatalog: vi.fn(() => ({ fonts: [] })), + upsertSlideContent: vi.fn(() => Promise.reject(new Error('not used'))), + generateImage: vi.fn(() => Promise.resolve({ assetId: 'asset-1' })), + getSlidePreview: vi.fn(() => ({ slideId: 'page-1' })), + getAiModelStatus: vi.fn(() => Promise.resolve({ models: [] })), + prepareAiModels: vi.fn(() => Promise.resolve([])), + searchMedia: vi.fn(() => Promise.resolve({ results: [] })), + exportPresentation: vi.fn(() => Promise.resolve({ fileName: 'deck.pptx' })), + publishPresentation: vi.fn(() => Promise.resolve({ publicUrl: 'https://example.test/deck' })), + ...overrides, + }; +} - adapter.register({ registerTools }); +function createAdapter(delegate = createDelegate()) { + return new WebMcpToolAdapter( + new authoringAutomationController.AuthoringAutomationController(delegate), + ); +} + +describe('WebMcpToolAdapter', () => { + it('registers only the refined authoring catalog with safety metadata', () => { + const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); + createAdapter().register({ registerTools }); const tools = registerTools.mock.calls[0]?.[0] ?? []; - expect(tools).toHaveLength(5); - expect(tools.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); - const generateSlidesTool = tools.find((tool) => tool.name === 'generate_slides'); - const generateImageTool = tools.find((tool) => tool.name === 'generate_image'); - expect(generateSlidesTool?.description).toContain(promptRecipes.slidePromptExamples[0]); - expect(generateImageTool?.description).toContain(promptRecipes.imagePromptExamples[0]); + expect(tools.map((tool) => tool.name)).toEqual(expectedToolNames); + expect(tools).toHaveLength(15); + expect(tools.every((tool) => Boolean(tool.title))).toBe(true); + expect(tools.every((tool) => tool.annotations?.untrustedContentHint)).toBe(true); + expect(tools.find((tool) => tool.name === 'generate_image')?.description).toContain( + 'upsert_slide_content', + ); }); - it('runs WebMCP cleanup callbacks returned by the browser runtime', () => { + it('runs cleanup callbacks returned by the browser runtime', () => { const cleanup = vi.fn(); - const adapter = createAdapter(); - const unregister = adapter.register({ - registerTools: vi.fn(() => cleanup), - }); + const unregister = createAdapter().register({ registerTools: vi.fn(() => cleanup) }); unregister(); expect(cleanup).toHaveBeenCalledTimes(1); }); - it('ignores duplicate WebMCP tool registration errors from an existing runtime registration', () => { - const adapter = createAdapter(); + it('ignores duplicate tool registration errors', () => { const registerTool = vi.fn(() => { throw new DOMException('Duplicate tool name', 'InvalidStateError'); }); - expect(() => adapter.register({ registerTool })).not.toThrow(); - expect(registerTool).toHaveBeenCalledTimes(5); + expect(() => createAdapter().register({ registerTool })).not.toThrow(); + expect(registerTool).toHaveBeenCalledTimes(15); }); - it('forwards tool calls to the automation controller', async () => { - const generateSlides = vi.fn(() => - Promise.resolve({ ok: true as const, data: { snapshot: { projectId: 'project-1' } as never } }), + it('normalizes create presentation input before forwarding it', async () => { + const createPresentation = vi.fn(() => ({ projectId: 'project-1', name: 'WebMCP Deck' })); + const adapter = createAdapter(createDelegate({ createPresentation })); + const tool = adapter + .createTools() + .find((candidate) => candidate.name === 'create_presentation'); + + await expect(tool?.execute({ name: 'WebMCP Deck', width: 1600, height: 900 })).resolves.toEqual( + { + ok: true, + data: { projectId: 'project-1', name: 'WebMCP Deck' }, + }, ); - const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); - const adapter = new WebMcpToolAdapter({ - createProject: vi.fn(), - generateSlides, - generateImage: vi.fn(), - translateText: vi.fn(), - getProjectSnapshot: vi.fn(), + expect(createPresentation).toHaveBeenCalledWith({ + name: 'WebMCP Deck', + width: 1600, + height: 900, }); + }); - adapter.register({ registerTools }); - const tools = registerTools.mock.calls[0]?.[0] ?? []; - const generateSlidesTool = tools.find((tool) => tool.name === 'generate_slides'); + it('publishes a strict discriminated schema for slide upserts', () => { + const tool = createAdapter() + .createTools() + .find((candidate) => candidate.name === 'upsert_slide_content'); + const schema = tool?.inputSchema as { + additionalProperties?: boolean; + oneOf?: unknown[]; + properties?: { elements?: { items?: { oneOf?: unknown[] } } }; + }; - await expect(generateSlidesTool?.execute({ prompt: 'Three-image grid about Web AI, with matching captions.' })).resolves.toEqual({ - ok: true, - data: { snapshot: { projectId: 'project-1' } }, - }); - expect(generateSlides).toHaveBeenCalledWith({ - prompt: 'Three-image grid about Web AI, with matching captions.', - }); + expect(schema.additionalProperties).toBe(false); + expect(schema.oneOf).toHaveLength(2); + expect(schema.properties?.elements?.items?.oneOf).toHaveLength(5); }); }); diff --git a/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx b/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx index 16da3d57..1da1ed52 100644 --- a/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx +++ b/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx @@ -9,13 +9,26 @@ import type { import { EditorShell } from '../../../../src/ui/editor/shell/EditorShell'; import { editorShellTestHarness } from './EditorShell.test-harness'; -const { - createAppServices, - openLeftTab, - selectImageLayer, -} = editorShellTestHarness; +const { createAppServices, openLeftTab, selectImageLayer } = editorShellTestHarness; const originalMatchMedia = window.matchMedia; +const authoringToolNames = [ + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', + 'generate_image', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', +]; describe('EditorShell', () => { afterEach(() => { @@ -54,13 +67,7 @@ describe('EditorShell', () => { expect(registerTools).toHaveBeenCalled(); }); const tools = registerTools.mock.calls[0]?.[0] ?? []; - expect(tools.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect(tools.map((tool) => tool.name)).toEqual(authoringToolNames); }); it('can disable WebMCP protocol registration from the editor URL', async () => { @@ -86,15 +93,11 @@ describe('EditorShell', () => { render(); await waitFor(() => { - expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(5); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(15); }); - expect((window as WebMcpDemoWindow).localStudioWebMcpTools?.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools?.map((tool) => tool.name)).toEqual( + authoringToolNames, + ); }); it('keeps WebMCP embeds available inside narrow host frames', async () => { @@ -115,7 +118,7 @@ describe('EditorShell', () => { expect( screen.queryByRole('heading', { name: 'Open this workspace on a desktop screen.' }), ).not.toBeInTheDocument(); - expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(5); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(15); }); it('renders the approved editor shell landmarks', async () => { diff --git a/tests/e2e/editor/coverage-service-contracts.spec.ts b/tests/e2e/editor/coverage-service-contracts.spec.ts index 2a0d83ec..585dca8a 100644 --- a/tests/e2e/editor/coverage-service-contracts.spec.ts +++ b/tests/e2e/editor/coverage-service-contracts.spec.ts @@ -41,7 +41,10 @@ import { evaluateProgressContract } from './progress-contract-browser'; import { evaluateProjectMutationUtilsContract } from './project-mutation-utils-contract-browser'; import { evaluateSampleProjectContract } from './sample-project-contract-browser'; import { serviceContractsSupport } from './service-contracts-support'; -import { createMirrorStorageContractProject, createStorageContractProject } from './storage-contract-project'; +import { + createMirrorStorageContractProject, + createStorageContractProject, +} from './storage-contract-project'; import { storageContractRuntimePage } from './storage-contract-runtime-page'; import { evaluateStorageDisabledContract } from './storage-disabled-contract-browser'; import { evaluateStorageMirrorImportContract } from './storage-mirror-import-contract-browser'; @@ -154,8 +157,16 @@ test.describe('editor service contracts coverage batch', () => { pptx: pptxPatcherContractFixtures.createInput(), }); - expect(result.generatedTexts).toEqual(['plain', 'chat content', 'nested chat', 'object text']); - expect(result.detectedLanguages).toEqual([{ language: 'pt', score: 0.91 }, { language: 'en' }]); + expect(result.generatedTexts).toEqual([ + 'plain', + 'chat content', + 'nested chat', + 'object text', + ]); + expect(result.detectedLanguages).toEqual([ + { language: 'pt', score: 0.91 }, + { language: 'en' }, + ]); expect(result.parsingErrors).toHaveLength(2); expect(result.patchedWarningCodes).toEqual( expect.arrayContaining([ @@ -202,10 +213,7 @@ test.describe('editor service contracts coverage batch', () => { expect(result.session.stateCount).toBeGreaterThan(0); expect(result.speech).toMatchObject({ changedSpeechLanguage: 'en-US', - errors: [ - 'Microphone permission is required for live transcription.', - 'Network down', - ], + errors: ['Microphone permission is required for live transcription.', 'Network down'], text: 'ola mundo', }); expect(result.speech.updates).toContainEqual({ final: false, text: 'ola mundo' }); @@ -322,7 +330,11 @@ test.describe('editor service contracts coverage batch', () => { translationState: { progress: 100, status: 'ready' }, }); expect(download.modelLoads).toEqual( - expect.arrayContaining(['image-editing', 'image-generation', expect.stringMatching(/^text:/)]), + expect.arrayContaining([ + 'image-editing', + 'image-generation', + expect.stringMatching(/^text:/), + ]), ); expect(download.storageWrites).toEqual( expect.arrayContaining([expect.stringMatching(/:true$/)]), @@ -462,7 +474,10 @@ test.describe('editor service contracts coverage batch', () => { expect.arrayContaining([expect.objectContaining({ progress: 50 })]), ); - const language = await transformersRuntimeContractPage.runLanguageWorkerContract(page, baseURL); + const language = await transformersRuntimeContractPage.runLanguageWorkerContract( + page, + baseURL, + ); expect(language).toMatchObject({ detectedLanguage: { language: 'es', score: 0.92 }, requests: ['preload-language-detection', 'detect-language'], @@ -551,12 +566,16 @@ test.describe('editor service contracts coverage batch', () => { expect.arrayContaining([ expect.stringContaining('info:[LocalStudio presenter remote]|enabled'), expect.stringContaining('warn:[LocalStudio presenter remote]|object|{"ok":true}'), - expect.stringContaining('error:[LocalStudio presenter remote]|failure|TypeError: bad stream'), + expect.stringContaining( + 'error:[LocalStudio presenter remote]|failure|TypeError: bad stream', + ), expect.stringContaining('warn:[LocalStudio presenter remote]|circular|[object Object]'), ]), ); expect(logging.logs).not.toEqual( - expect.arrayContaining([expect.stringContaining('info:[LocalStudio presenter remote]|ready')]), + expect.arrayContaining([ + expect.stringContaining('info:[LocalStudio presenter remote]|ready'), + ]), ); await gotoNewProject(page); @@ -726,7 +745,9 @@ test.describe('editor service contracts coverage batch', () => { trustedOffer: { status: 'pending' }, untrustedOffer: { status: 'not-found' }, }); - expect(offer.pendingOffers).toEqual([{ controllerId: 'controller-1', offerSdp: 'offer-sdp' }]); + expect(offer.pendingOffers).toEqual([ + { controllerId: 'controller-1', offerSdp: 'offer-sdp' }, + ]); const answer = await presenterSignalingWebRtcContractPage.runAnswer(page, options); expect(answer).toMatchObject({ @@ -756,13 +777,23 @@ test.describe('editor service contracts coverage batch', () => { evaluateWebMcpToolAdapterMetadataContract, ); expect(metadata.toolNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', ]); - expect(metadata.toolDescriptions.join('\n')).toContain('Good prompt examples'); + expect(metadata.toolTitles.every(Boolean)).toBe(true); const execution = await webMcpContractPage.run( page, @@ -770,22 +801,16 @@ test.describe('editor service contracts coverage batch', () => { evaluateWebMcpToolAdapterExecutionContract, ); expect(execution).toMatchObject({ - createProjectBlank: { data: { name: 'Untitled' }, ok: true }, - createProjectNamed: { data: { name: 'WebMCP Deck' }, ok: true }, - generatedImage: { data: { assetId: 'asset-generated' }, ok: true }, - generatedSlides: { data: { prompt: 'Create a launch slide' }, ok: true }, - snapshot: { data: { pageCount: 1 }, ok: true }, - translated: { data: { scope: 'slide' }, ok: true }, - translatedWithoutPage: { data: { scope: 'deck' }, ok: true }, + created: { data: { name: 'WebMCP Deck' }, ok: true }, + state: { data: { pageCount: 1 }, ok: true }, + preview: { data: { slideId: 'page-1' }, ok: true }, + imageStatus: { data: { state: 'completed' }, ok: true }, }); expect(execution.controllerCalls.map((call) => call.name)).toEqual([ - 'createProject', - 'createProject', - 'generateSlides', + 'createPresentation', + 'getPresentationState', + 'getSlidePreview', 'generateImage', - 'translateText', - 'translateText', - 'getProjectSnapshot', ]); const registration = await webMcpContractPage.run( @@ -793,13 +818,7 @@ test.describe('editor service contracts coverage batch', () => { baseURL, evaluateWebMcpToolAdapterRegistrationContract, ); - expect(registration.registeredNames).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect(registration.registeredNames).toEqual(metadata.toolNames); expect(registration.individuallyRegisteredNames).toEqual(registration.registeredNames); }); }); diff --git a/tests/e2e/editor/service-contracts-webmcp.spec.ts b/tests/e2e/editor/service-contracts-webmcp.spec.ts index eacbd250..71ec847c 100644 --- a/tests/e2e/editor/service-contracts-webmcp.spec.ts +++ b/tests/e2e/editor/service-contracts-webmcp.spec.ts @@ -5,9 +5,7 @@ import { evaluateWebMcpToolAdapterExecutionContract } from './webmcp-tool-adapte import { evaluateWebMcpToolAdapterMetadataContract } from './webmcp-tool-adapter-metadata-contract-browser'; import { evaluateWebMcpToolAdapterRegistrationContract } from './webmcp-tool-adapter-registration-contract-browser'; -test('executes WebMCP tool adapter metadata contracts in the browser runtime', async ({ - page, -}) => { +test('executes WebMCP tool adapter metadata contracts in the browser runtime', async ({ page }) => { const result = await webMcpContractPage.run( page, serviceContractsSupport.getServer().baseURL, @@ -15,13 +13,31 @@ test('executes WebMCP tool adapter metadata contracts in the browser runtime', a ); expect(result.toolNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', + ]); + expect(result.toolTitles.every(Boolean)).toBe(true); + expect(result.readOnlyNames).toEqual([ + 'get_presentation_state', + 'list_authoring_catalog', + 'get_slide_preview', + 'get_ai_model_status', + 'search_media', + 'get_operation_status', ]); - expect(result.toolDescriptions.join('\n')).toContain('Good prompt examples'); }); test('executes WebMCP tool adapter execution contracts in the browser runtime', async ({ @@ -34,22 +50,17 @@ test('executes WebMCP tool adapter execution contracts in the browser runtime', ); expect(result).toMatchObject({ - createProjectBlank: { data: { name: 'Untitled' }, ok: true }, - createProjectNamed: { data: { name: 'WebMCP Deck' }, ok: true }, - generatedImage: { data: { assetId: 'asset-generated' }, ok: true }, - generatedSlides: { data: { prompt: 'Create a launch slide' }, ok: true }, - snapshot: { data: { pageCount: 1 }, ok: true }, - translated: { data: { scope: 'slide' }, ok: true }, - translatedWithoutPage: { data: { scope: 'deck' }, ok: true }, + created: { data: { name: 'WebMCP Deck', projectId: 'project-1' }, ok: true }, + state: { data: { pageCount: 1, projectId: 'project-1' }, ok: true }, + preview: { data: { slideId: 'page-1', slideNumber: 1 }, ok: true }, + imageOperation: { data: { status: 'queued' }, ok: true }, + imageStatus: { data: { state: 'completed', result: { assetId: 'asset-generated' } }, ok: true }, }); expect(result.controllerCalls.map((call) => call.name)).toEqual([ - 'createProject', - 'createProject', - 'generateSlides', + 'createPresentation', + 'getPresentationState', + 'getSlidePreview', 'generateImage', - 'translateText', - 'translateText', - 'getProjectSnapshot', ]); }); @@ -63,15 +74,25 @@ test('executes WebMCP tool adapter registration contracts in the browser runtime ); expect(result.registeredNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', ]); expect(result.individuallyRegisteredNames).toEqual(result.registeredNames); - expect(result.batchCleanupCount).toBe(5); - expect(result.individualCleanupCount).toBe(5); + expect(result.batchCleanupCount).toBe(15); + expect(result.individualCleanupCount).toBe(15); expect(result.duplicateBatchIgnored).toBe(true); expect(result.duplicateIndividualIgnored).toBe(true); expect(result.nonDuplicateErrorName).toBe('registration failed'); diff --git a/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts index 0a2653a8..c9ab2615 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts @@ -1,73 +1,70 @@ export type WebMcpToolAdapterExecutionContractResult = { controllerCalls: Array<{ input: unknown; name: string }>; - createProjectBlank: unknown; - createProjectNamed: unknown; - generatedImage: unknown; - generatedSlides: unknown; - snapshot: unknown; - translated: unknown; - translatedWithoutPage: unknown; + created: unknown; + imageOperation: unknown; + imageStatus: unknown; + preview: unknown; + state: unknown; }; export async function evaluateWebMcpToolAdapterExecutionContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; const controllerCalls: Array<{ input: unknown; name: string }> = []; - const adapter = new WebMcpToolAdapter({ - createProject: (input) => { - controllerCalls.push({ input, name: 'createProject' }); - return { data: { name: input.name ?? 'Untitled' }, ok: true }; - }, - generateImage: (input) => { - controllerCalls.push({ input, name: 'generateImage' }); - return { data: { assetId: 'asset-generated' }, ok: true }; + const unused = () => Promise.resolve({}); + const controller = new authoringAutomationController.AuthoringAutomationController({ + createPresentation: (input) => { + controllerCalls.push({ input, name: 'createPresentation' }); + return { projectId: 'project-1', name: input.name ?? 'Untitled' }; }, - generateSlides: (input) => { - controllerCalls.push({ input, name: 'generateSlides' }); - return { data: { prompt: input.prompt }, ok: true }; + getPresentationState: (input) => { + controllerCalls.push({ input, name: 'getPresentationState' }); + return { projectId: 'project-1', pageCount: 1 }; }, - getProjectSnapshot: () => { - controllerCalls.push({ input: {}, name: 'getProjectSnapshot' }); - return { data: { pageCount: 1 }, ok: true }; + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: (input, report) => { + controllerCalls.push({ input, name: 'generateImage' }); + report({ stage: 'generating-image', progress: 70 }); + return Promise.resolve({ assetId: 'asset-generated' }); }, - translateText: (input) => { - controllerCalls.push({ input, name: 'translateText' }); - return { data: { scope: input.scope }, ok: true }; + getSlidePreview: (input) => { + controllerCalls.push({ input, name: 'getSlidePreview' }); + return { slideId: 'page-1', slideNumber: input.slideNumber }; }, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, }); - const tools = adapter.createTools(); - const createProjectNamed = await tools[0].execute({ name: 'WebMCP Deck' }); - const createProjectBlank = await tools[0].execute({ name: 123 }); - const generatedSlides = await tools[1].execute({ prompt: 'Create a launch slide' }); - const generatedImage = await tools[2].execute({ - height: 512, - prompt: 'neon card', - seed: Number.NaN, - steps: 8, - width: 512, - }); - const translated = await tools[3].execute({ - pageId: 'page-1', - scope: 'slide', - targetLanguage: 'pt', - }); - const translatedWithoutPage = await tools[3].execute({ - pageId: 5, - scope: 'deck', - targetLanguage: 'es', - }); - const snapshot = await tools[4].execute({}); + const tools = new WebMcpToolAdapter(controller).createTools(); + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const created = await byName + .get('create_presentation')! + .execute({ name: 'WebMCP Deck', width: 1600, height: 900 }); + const state = await byName.get('get_presentation_state')!.execute({ detail: 'summary' }); + const preview = await byName.get('get_slide_preview')!.execute({ slideNumber: 1 }); + const imageOperation = await byName + .get('generate_image')! + .execute({ prompt: 'neon card', width: 512, height: 512 }); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + const operationId = + imageOperation.ok && + typeof imageOperation.data === 'object' && + imageOperation.data && + 'operationId' in imageOperation.data + ? String(imageOperation.data.operationId) + : ''; + const imageStatus = await byName.get('get_operation_status')!.execute({ operationId }); - return { - controllerCalls, - createProjectBlank, - createProjectNamed, - generatedImage, - generatedSlides, - snapshot, - translated, - translatedWithoutPage, - }; + return { controllerCalls, created, imageOperation, imageStatus, preview, state }; } diff --git a/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts index ac60e1e6..dfc2a9d9 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts @@ -1,24 +1,41 @@ export type WebMcpToolAdapterMetadataContractResult = { + readOnlyNames: string[]; toolDescriptions: string[]; toolNames: string[]; + toolTitles: string[]; }; export async function evaluateWebMcpToolAdapterMetadataContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - - const adapter = new WebMcpToolAdapter({ - createProject: () => ({ data: {}, ok: true }), - generateImage: () => ({ data: {}, ok: true }), - generateSlides: () => ({ data: {}, ok: true }), - getProjectSnapshot: () => ({ data: {}, ok: true }), - translateText: () => ({ data: {}, ok: true }), + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; + const unused = () => Promise.resolve({}); + const controller = new authoringAutomationController.AuthoringAutomationController({ + createPresentation: unused, + getPresentationState: unused, + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: unused, + getSlidePreview: unused, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, }); - const tools = adapter.createTools(); + const tools = new WebMcpToolAdapter(controller).createTools(); return { + readOnlyNames: tools.filter((tool) => tool.annotations?.readOnlyHint).map((tool) => tool.name), toolDescriptions: tools.map((tool) => tool.description), toolNames: tools.map((tool) => tool.name), + toolTitles: tools.map((tool) => tool.title), }; } diff --git a/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts index 6c4ab267..42609dfd 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts @@ -9,17 +9,32 @@ export type WebMcpToolAdapterRegistrationContractResult = { }; export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - - const adapter = new WebMcpToolAdapter({ - createProject: () => ({ data: {}, ok: true }), - generateImage: () => ({ data: {}, ok: true }), - generateSlides: () => ({ data: {}, ok: true }), - getProjectSnapshot: () => ({ data: {}, ok: true }), - translateText: () => ({ data: {}, ok: true }), - }); + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; + const unused = () => Promise.resolve({}); + const adapter = new WebMcpToolAdapter( + new authoringAutomationController.AuthoringAutomationController({ + createPresentation: unused, + getPresentationState: unused, + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: unused, + getSlidePreview: unused, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, + }), + ); const registeredNames: string[] = []; let batchCleanupCount = 0; const unregisterBatch = adapter.register({ @@ -31,16 +46,14 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { adapter.register({ registerTools: () => { - throw new DOMException('Duplicate tool name: create_project', 'InvalidStateError'); + throw new DOMException('Duplicate tool name', 'InvalidStateError'); }, }); return true; })(); - const individuallyRegisteredNames: string[] = []; let individualCleanupCount = 0; const unregisterIndividual = adapter.register({ @@ -52,16 +65,14 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { adapter.register({ registerTool: () => { - throw new DOMException('Duplicate tool name: create_project', 'InvalidStateError'); + throw new DOMException('Duplicate tool name', 'InvalidStateError'); }, }); return true; })(); - let nonDuplicateErrorName = ''; try { adapter.register({ @@ -72,7 +83,6 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { ).toBeVisible(); await page.getByRole('button', { name: 'Discover tools' }).click(); await expect(page.getByText(/Discovered \d+ tools/)).toBeVisible(); - await expect(page.getByRole('button', { name: 'create_project' })).toBeVisible(); - await page.getByRole('button', { name: 'Create project' }).click(); - await expect(page.getByLabel('Create project command input')).toBeVisible(); - await page.getByLabel('Create project command input').fill('E2E WebMCP project'); - await page.getByRole('button', { name: 'Send Create project' }).click(); - await expect(page.getByText('Create project completed.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'create_presentation' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'import_powerpoint_from_disk' })).toHaveCount(0); + await page.getByRole('button', { name: 'Create presentation' }).click(); + await expect(page.getByLabel('Create presentation command input')).toBeVisible(); + await page.getByLabel('Create presentation command input').fill('E2E WebMCP project'); + await page.getByRole('button', { name: 'Send Create presentation' }).click(); + await expect(page.getByText('Create presentation completed.')).toBeVisible(); await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( 'E2E WebMCP project', ); - await page.getByRole('button', { name: 'get_project_snapshot' }).click(); - await page.getByRole('button', { name: 'Read snapshot' }).click(); - await expect(page.getByText('Read snapshot completed.')).toBeVisible(); + await page.getByRole('button', { name: 'Upsert slide' }).click(); + await expect(page.getByLabel('Upsert slide command input')).toBeVisible(); + await page.getByRole('button', { name: 'Send Upsert slide' }).click(); + await expect(page.getByText('Upsert slide completed.')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( + 'idempotentReplay', + ); + await expect( + page + .frameLocator('iframe[title="LocalStudio editor WebMCP demo"]') + .getByText(/Page 1.*Agent-native presentations/), + ).toBeVisible(); + + await page.getByRole('button', { name: 'get_presentation_state' }).click(); + await page.getByRole('button', { name: 'Read presentation state' }).click(); + await expect(page.getByText('Read presentation state completed.')).toBeVisible(); await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( 'E2E WebMCP project', ); + await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( + 'Presentations become agent-native', + ); }); });