diff --git a/apps/docs/guide/work-with-web-ai/webmcp.md b/apps/docs/guide/work-with-web-ai/webmcp.md index ee674f7c..fbb184ae 100644 --- a/apps/docs/guide/work-with-web-ai/webmcp.md +++ b/apps/docs/guide/work-with-web-ai/webmcp.md @@ -1,6 +1,6 @@ # WebMCP authoring -WebMCP lets an agent use LocalStudio's real editor commands while you watch the same canvas. The editor exposes 15 narrowly scoped tools with JSON schemas, bounded results, and visible state changes. The showcase at `/editor/webmcp/` provides an editable card for every tool. +WebMCP lets an agent use LocalStudio's real editor commands while you watch the same canvas. The editor exposes 14 narrowly scoped tools with JSON schemas, bounded results, and visible state changes. The showcase at `/editor/webmcp/` provides an editable card for every tool. This is an **authoring** integration. Public presentations can contain slide descriptions, transcripts, and authorized recording audio, but the public viewer does not currently register attendee WebMCP tools. @@ -12,35 +12,33 @@ This is an **authoring** integration. Public presentations can contain slide des - For native agent discovery, open `http://localhost:5184/editor/?webmcp=1` directly. WebMCP is document-scoped, so a browser agent does not inherit tools registered by the showcase's child iframe. - Translation, description generation, image generation, and model preparation may require browser AI support, WebGPU, model downloads, and several gigabytes of browser storage. - Unsplash and GIPHY search require their respective keys in **Settings > Media integrations**. -- Publishing requires configured S3-compatible remote storage. The local MinIO settings in the project README are suitable for development. -## The 15 tools +## The 14 tools All inputs are JSON objects. Unknown fields and invalid types are rejected before execution. -| Tool | Input summary | Result or effect | -| ------------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------- | -| `create_presentation` | Optional `name`, `width`, `height` | Replaces the editor with a blank presentation. | -| `get_presentation_state` | `detail`, optional slide and pagination fields | Returns bounded project, slide, element, revision, and freshness state. | -| `import_powerpoint_from_url` | Required HTTP(S) `url`; optional safe `.pptx` `fileName` | Starts native PPTX import and font resolution. | -| `translate_deck_and_notes` | Required `targetLanguage`; optional `sourceLanguage` | Starts translation of visible text, notes, and existing descriptions. | -| `generate_deck_detailed_description` | Optional `slideNumbers`, `language`, `force` | Starts fresh, revision-linked semantic descriptions. | -| `list_authoring_catalog` | `kind: "fonts"`; or animations plus `elementType` | Returns bounded usable font or animation metadata. | -| `upsert_slide_content` | Stable `requestId`, slide, mode, and typed elements | Atomically merges or replaces exact slide primitives. | -| `generate_image` | `prompt`; optional dimensions, seed, and steps | Starts image generation and returns an asset ID without placing it. | -| `get_slide_preview` | One-based `slideNumber` | Selects and fits the slide and returns dimensions, count, and render hash. | -| `get_ai_model_status` | `{}` | Reports browser support, providers, model readiness, sizes, and errors. | -| `prepare_ai_models` | Optional `modelIds`; omit to prepare required models | Starts model downloads and preparation. | -| `search_media` | `kind`, `term`, optional `limit` | Returns bounded Unsplash or GIPHY references and attribution. | -| `export_presentation` | `format`, optional `slideRange` and `includeAnimationFrames` | Starts a native PPTX/PDF/PNG/JPEG download. | -| `publish_presentation` | Optional stable `shareId` and `expectedRevision` | Publishes an exact snapshot and returns public/embed URLs and its revision. | -| `get_operation_status` | `operationId`, optional `waitForChangeMs` | Returns queued, running, completed, or failed operation state. | +| Tool | Input summary | Result or effect | +| ------------------------------------ | ------------------------------------------------------------ | -------------------------------------------------------------------------- | +| `create_presentation` | Optional `name`, `width`, `height` | Replaces the editor with a blank presentation. | +| `get_presentation_state` | `detail`, optional slide and pagination fields | Returns bounded project, slide, element, revision, and freshness state. | +| `import_powerpoint_from_url` | Required HTTP(S) `url`; optional safe `.pptx` `fileName` | Starts native PPTX import and font resolution. | +| `translate_deck_and_notes` | Required `targetLanguage`; optional `sourceLanguage` | Starts translation of visible text, notes, and existing descriptions. | +| `generate_deck_detailed_description` | Optional `slideNumbers`, `language`, `force` | Starts fresh, revision-linked semantic descriptions. | +| `list_authoring_catalog` | `kind: "fonts"`; or animations plus `elementType` | Returns bounded usable font or animation metadata. | +| `upsert_slide_content` | Stable `requestId`, slide, mode, and typed elements | Atomically merges or replaces exact slide primitives. | +| `generate_image` | `prompt`; optional dimensions, seed, and steps | Starts image generation and returns an asset ID without placing it. | +| `get_slide_preview` | One-based `slideNumber` | Selects and fits the slide and returns dimensions, count, and render hash. | +| `get_ai_model_status` | `{}` | Reports browser support, providers, model readiness, sizes, and errors. | +| `prepare_ai_models` | Optional `modelIds`; omit to prepare required models | Starts model downloads and preparation. | +| `search_media` | `kind`, `term`, optional `limit` | Returns bounded Unsplash or GIPHY references and attribution. | +| `export_presentation` | `format`, optional `slideRange` and `includeAnimationFrames` | Starts a native PPTX/PDF/PNG/JPEG download. | +| `get_operation_status` | `operationId`, optional `waitForChangeMs` | Returns queued, running, completed, or failed operation state. | Reader tools are marked read-only. Results containing imported or user-authored content are marked untrusted so slide text and metadata remain evidence rather than instructions. ## Long-running operations -Import, translation, description generation, image generation, model preparation, export, and publishing return an operation ID immediately: +Import, translation, description generation, image generation, model preparation, and export return an operation ID immediately: ```json { @@ -73,15 +71,15 @@ The server must: - return a successful HTTP status; - return the PPTX MIME type or `application/octet-stream`; - provide a safe `.pptx` name in the URL, `Content-Disposition`, or `fileName` input; -- stay within the configured size limit. +- stay within any deployment-specific size limit. LocalStudio does not impose a default PPTX size cap. -LocalStudio streams and bounds the download, then uses the same native parser, mapper, warnings, normalization, and font workflow as visible PowerPoint import. Expired URLs, unreachable servers, CORS failures, wrong MIME types, oversized files, and corrupt packages fail without replacing the current project. +LocalStudio streams the download and enforces a deployment-specific limit when one is configured, then uses the same native parser, mapper, warnings, normalization, and font workflow as visible PowerPoint import. Expired URLs, unreachable servers, CORS failures, wrong MIME types, configured-limit violations, and corrupt packages fail without replacing the current project. The normal **File > Import > PowerPoint** picker remains available for a person using the editor. It is intentionally not exposed as a WebMCP disk-import tool. ## Manual authoring workflow -1. Open `/editor/webmcp/` and select **Discover tools**. Confirm that 15 tools appear. +1. Open `/editor/webmcp/` and select **Discover tools**. Confirm that 14 tools appear. 2. Run **Create presentation**, then **Upsert slide content**. Confirm the embedded canvas changes. 3. Run **Inspect presentation state** and compare the returned slide revision and elements with the canvas. 4. Optionally import a CORS-enabled PPTX URL. Poll the operation and review page, byte, font, and warning counts. @@ -89,21 +87,19 @@ The normal **File > Import > PowerPoint** picker remains available for a person 6. Run **Translate deck and notes** and **Generate detailed descriptions**. Poll both operations; inspect changed/skipped slides, failures, overflow warnings, description language, generator, timestamp, source revision, reviewed state, and freshness. 7. Run **Focus slide preview** before visual inspection. 8. Run each export format. Inspect the downloaded PPTX/PDF or PNG/JPEG ZIP rather than relying only on the success message. -9. Read the current presentation revision, configure remote storage, then run **Publish presentation** with that value as `expectedRevision`. -10. Open the returned public URL in a clean browser context and confirm its authoring revision matches. The published snapshot includes mirrored fonts, semantic descriptions, transcript context, and raw recording audio only when that recording was authorized for sharing. ### Cross-client check Run this small check in every supported agent browser: -1. Open `/editor/?webmcp=1` directly and confirm native discovery returns exactly 15 tools. +1. Open `/editor/?webmcp=1` directly and confirm native discovery returns exactly 14 tools. 2. Call `create_presentation`, `upsert_slide_content`, `get_presentation_state`, and `get_slide_preview` through the browser's WebMCP interface. 3. Confirm the state revision and preview render hash agree and visually inspect the same title on the canvas. -4. Open `/editor/webmcp/` separately and confirm its manual bridge finds the same 15 names and exposes 15 editable cards. +4. Open `/editor/webmcp/` separately and confirm its manual bridge finds the same 14 names and exposes 14 editable cards. The direct editor route proves browser-native WebMCP. The showcase proves the judge-friendly manual control surface; they are complementary checks. -For a short judge demo, show the sequence import → translate → describe → preview → publish. Keep the operation-status card visible, show the canvas changing after each mutation, and finish by opening the returned URL in a clean context. Export, media search, catalogs, AI status, model preparation, and image generation can be shown briefly through their editable cards to demonstrate the complete catalog. +For a short judge demo, show the sequence import → translate → describe → preview → export. Keep the operation status visible in both the editor and the action card, show the canvas changing after each mutation, and finish by inspecting the exported artifact. Media search, catalogs, AI status, model preparation, and image generation can be shown briefly through their editable cards to demonstrate the complete catalog. ## Failures and recovery @@ -113,9 +109,7 @@ For a short judge demo, show the sequence import → translate → describe → - **Import fails:** check URL expiry, HTTP status, CORS, MIME type, filename, size, and whether the file is a valid PPTX package. - **Media search fails:** configure the matching Unsplash or GIPHY integration. - **AI preparation or generation fails:** inspect `get_ai_model_status`, browser compatibility, free storage, WebGPU support, and model errors. -- **Publish fails before upload:** configure remote storage. If `expectedRevision` is stale, read state again and retry with the new revision. -- **Publish fails during upload:** verify S3/MinIO endpoint, bucket policy, CORS, public base URL, and writer credentials; retrying the same stable share ID updates the same link. ## Public-viewer roadmap -Attendee-side tools for slide context, transcript search, recording metadata, and navigation are a separate future surface with a different read-only authorization model. They are not part of the shipped 15-tool authoring catalog and should not be presented as current behavior. +Attendee-side tools for slide context, transcript search, recording metadata, and navigation are a separate future surface with a different read-only authorization model. They are not part of the shipped 14-tool authoring catalog and should not be presented as current behavior. diff --git a/apps/editor/src/services/automation/authoringAutomationController.ts b/apps/editor/src/services/automation/authoringAutomationController.ts index 3ca2f129..088ba0ce 100644 --- a/apps/editor/src/services/automation/authoringAutomationController.ts +++ b/apps/editor/src/services/automation/authoringAutomationController.ts @@ -3,6 +3,7 @@ import type { SlideUpsertBatch, SlideUpsertResult } from './slideUpsertService'; import { AuthoringOperationRegistry, type AuthoringOperationProgress, + type AuthoringOperationStatus, } from './authoringOperationRegistry'; export type AuthoringResult = @@ -76,13 +77,6 @@ export interface AuthoringAutomationDelegate { }, report: AuthoringProgressReporter, ): Promise; - publishPresentation?( - input: { - shareId?: string | undefined; - expectedRevision?: string | undefined; - }, - report: AuthoringProgressReporter, - ): Promise; } function success(data: T): AuthoringResult { @@ -109,13 +103,20 @@ function canonicalize(value: unknown): unknown { } class AuthoringAutomationController { - private readonly operations = new AuthoringOperationRegistry(); + private readonly operations: AuthoringOperationRegistry; private readonly upserts = new Map< string, { inputKey: string; promise: Promise } >(); - constructor(private readonly delegate: AuthoringAutomationDelegate) {} + constructor( + private readonly delegate: AuthoringAutomationDelegate, + options: { + onOperationStatusChange?: (status: AuthoringOperationStatus) => void; + } = {}, + ) { + this.operations = new AuthoringOperationRegistry(options.onOperationStatusChange); + } async createPresentation(input: { name?: string | undefined; @@ -299,17 +300,6 @@ class AuthoringAutomationController { ); } - publishPresentation(input: { - shareId?: string | undefined; - expectedRevision?: 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; diff --git a/apps/editor/src/services/automation/authoringOperationRegistry.ts b/apps/editor/src/services/automation/authoringOperationRegistry.ts index 2d0a079d..8142b3b7 100644 --- a/apps/editor/src/services/automation/authoringOperationRegistry.ts +++ b/apps/editor/src/services/automation/authoringOperationRegistry.ts @@ -44,6 +44,8 @@ function cloneStatus(status: AuthoringOperationStatus): AuthoringOperationStatus export class AuthoringOperationRegistry { private readonly operations = new Map(); + constructor(private readonly onStatusChange?: (status: AuthoringOperationStatus) => void) {} + start(stage: string, task: OperationTask): AuthoringOperationStatus { const operationId = createOperationId(); const timestamp = new Date().toISOString(); @@ -59,6 +61,7 @@ export class AuthoringOperationRegistry { warnings: [], }; this.operations.set(operationId, status); + this.onStatusChange?.(cloneStatus(status)); void Promise.resolve().then(async () => { this.update(operationId, { state: 'running', progress: 1 }); @@ -94,7 +97,7 @@ export class AuthoringOperationRegistry { patch.progress === undefined ? current.progress : Math.max(current.progress, Math.min(100, Math.round(patch.progress))); - this.operations.set(operationId, { + const nextStatus: AuthoringOperationStatus = { ...current, ...patch, progress, @@ -102,6 +105,8 @@ export class AuthoringOperationRegistry { revision: current.revision + 1, updatedAt: new Date().toISOString(), warnings: patch.warnings ? [...patch.warnings] : current.warnings, - }); + }; + this.operations.set(operationId, nextStatus); + this.onStatusChange?.(cloneStatus(nextStatus)); } } diff --git a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts index 7bd9543a..e58717bd 100644 --- a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts +++ b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts @@ -7,7 +7,6 @@ import type { AuthoringAssetCapabilities } from './createAuthoringAssetCapabilit import type { deckLocalizationCapability } from './deckLocalizationCapability'; import type { createAuthoringVisualCapability } from './authoringVisualCapability'; import type { PowerPointUrlImportService } from './powerPointUrlImportService'; -import type { PresentationPublishingCapability } from './presentationPublishingCapability'; import { authoringRevision } from './getAuthoringSlideRevision'; import { slideUpsertService, @@ -23,7 +22,6 @@ interface CreateAuthoringDelegateOptions { replaceProject(project: ProjectDocument): void; applyProject(project: ProjectDocument, activePageId?: string): void; powerPointUrlImportService?: PowerPointUrlImportService | undefined; - publishingCapability?: PresentationPublishingCapability | undefined; visualCapability?: ReturnType | undefined; } @@ -129,7 +127,6 @@ export function createAuthoringAutomationDelegate( const assetCapabilities = options.assetCapabilities; const deckLocalization = options.deckLocalization; const powerPointUrlImportService = options.powerPointUrlImportService; - const publishingCapability = options.publishingCapability; const visualCapability = options.visualCapability; function resolveMedia( @@ -309,13 +306,5 @@ export function createAuthoringAutomationDelegate( ) => visualCapability.exportPresentation(input, report), } : {}), - ...(publishingCapability - ? { - publishPresentation: ( - input: Parameters>[0], - report: Parameters>[1], - ) => publishingCapability.publish(input, report), - } - : {}), }; } diff --git a/apps/editor/src/services/automation/deckDescriptionCapability.ts b/apps/editor/src/services/automation/deckDescriptionCapability.ts index 1a494365..3c2dd937 100644 --- a/apps/editor/src/services/automation/deckDescriptionCapability.ts +++ b/apps/editor/src/services/automation/deckDescriptionCapability.ts @@ -57,6 +57,7 @@ export interface DeckDescriptionResult { language: string; sourceRevision: string; freshness: 'fresh'; + text: string; }>; failures: DeckDescriptionFailure[]; failureCount: number; @@ -67,6 +68,7 @@ export interface DeckDescriptionResult { const descriptionLimits = { maxCharacters: 12_000, maxEntries: 100, + maxResultCharacters: 2_000, maxSceneElements: 100, maxSceneTextCharacters: 2_000, } as const; @@ -296,6 +298,7 @@ function createCapability(options: DeckLocalizationCapabilityOptions) { language: descriptionLanguage, sourceRevision, freshness: 'fresh', + text: text.slice(0, descriptionLimits.maxResultCharacters), }); report({ stage: 'describing-slides', diff --git a/apps/editor/src/services/automation/deckLocalizationCapability.ts b/apps/editor/src/services/automation/deckLocalizationCapability.ts index 19d77a92..3272922a 100644 --- a/apps/editor/src/services/automation/deckLocalizationCapability.ts +++ b/apps/editor/src/services/automation/deckLocalizationCapability.ts @@ -12,6 +12,8 @@ export interface DeckCapabilityProgress { current?: number; total?: number; detail?: string; + loadedBytes?: number; + totalBytes?: number; warnings?: string[]; } diff --git a/apps/editor/src/services/automation/deckTranslationCapability.ts b/apps/editor/src/services/automation/deckTranslationCapability.ts index 075ca051..9d616e0d 100644 --- a/apps/editor/src/services/automation/deckTranslationCapability.ts +++ b/apps/editor/src/services/automation/deckTranslationCapability.ts @@ -117,7 +117,16 @@ function createCapability(options: DeckLocalizationCapabilityOptions) { progress: 2, detail: `${detectedLanguage} to ${targetLanguage}`, }); - await options.translatorService.prepareTranslation(detectedLanguage, targetLanguage); + await options.translatorService.prepareTranslation(detectedLanguage, targetLanguage, { + onProgress: (progress, details) => + report({ + stage: 'preparing-translation-model', + progress: 2 + Math.round((Math.max(0, Math.min(100, progress)) / 100) * 8), + detail: `${detectedLanguage} to ${targetLanguage}`, + ...(details?.loadedBytes !== undefined ? { loadedBytes: details.loadedBytes } : {}), + ...(details?.totalBytes !== undefined ? { totalBytes: details.totalBytes } : {}), + }), + }); } const nextElements = { ...project.elements }; @@ -161,7 +170,9 @@ function createCapability(options: DeckLocalizationCapabilityOptions) { }); } if (translated !== element.text) { - nextElements[elementId] = { ...element, text: translated }; + const { paragraphs, ...elementWithoutParagraphs } = element; + void paragraphs; + nextElements[elementId] = { ...elementWithoutParagraphs, text: translated }; slideChanged = true; } translatedTextElements += 1; @@ -252,7 +263,7 @@ function createCapability(options: DeckLocalizationCapabilityOptions) { report({ stage: 'translating-slides', progress: project.pages.length - ? Math.round(((index + 1) / project.pages.length) * 90) + 5 + ? Math.round(((index + 1) / project.pages.length) * 85) + 10 : 95, current: index + 1, total: project.pages.length, diff --git a/apps/editor/src/services/automation/powerPointUrlImportService.ts b/apps/editor/src/services/automation/powerPointUrlImportService.ts index c72299f4..505456fc 100644 --- a/apps/editor/src/services/automation/powerPointUrlImportService.ts +++ b/apps/editor/src/services/automation/powerPointUrlImportService.ts @@ -30,7 +30,6 @@ export interface PowerPointUrlImportServiceOptions { const powerPointMimeType = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; const acceptedMimeTypes = new Set([powerPointMimeType, 'application/octet-stream']); -const defaultMaxFileSizeBytes = 100 * 1024 * 1024; const defaultMaxWarnings = 20; const maxWarningMessageLength = 500; @@ -125,18 +124,18 @@ function toBlobPart(bytes: Uint8Array) { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } -async function readBoundedResponse( +async function readResponseBytes( response: Response, - maxBytes: number, + maxBytes: number | undefined, report: AuthoringProgressReporter, ) { const declaredBytes = parseContentLength(response); - if (declaredBytes !== undefined && declaredBytes > maxBytes) { + if (maxBytes !== undefined && declaredBytes !== undefined && declaredBytes > maxBytes) { return fail('file-too-large', `The file exceeds the ${maxBytes.toLocaleString()} byte limit.`); } if (!response.body) { const buffer = await response.arrayBuffer(); - if (buffer.byteLength > maxBytes) { + if (maxBytes !== undefined && buffer.byteLength > maxBytes) { return fail( 'file-too-large', `The file exceeds the ${maxBytes.toLocaleString()} byte limit.`, @@ -153,12 +152,13 @@ async function readBoundedResponse( const reader = response.body.getReader(); const chunks: ArrayBuffer[] = []; let loadedBytes = 0; + let lastReportedProgress = 5; try { while (true) { const { done, value } = await reader.read(); if (done) break; loadedBytes += value.byteLength; - if (loadedBytes > maxBytes) { + if (maxBytes !== undefined && loadedBytes > maxBytes) { await reader.cancel('PowerPoint file exceeds configured size limit.'); return fail( 'file-too-large', @@ -169,12 +169,15 @@ async function readBoundedResponse( const downloadProgress = declaredBytes ? 5 + Math.min(40, Math.round((loadedBytes / declaredBytes) * 40)) : Math.min(44, 5 + Math.floor(loadedBytes / (1024 * 1024))); - report({ - stage: 'downloading-powerpoint', - progress: downloadProgress, - loadedBytes, - ...(declaredBytes !== undefined ? { totalBytes: declaredBytes } : {}), - }); + if (downloadProgress !== lastReportedProgress) { + lastReportedProgress = downloadProgress; + report({ + stage: 'downloading-powerpoint', + progress: downloadProgress, + loadedBytes, + ...(declaredBytes !== undefined ? { totalBytes: declaredBytes } : {}), + }); + } } } finally { reader.releaseLock(); @@ -208,14 +211,17 @@ function getDefaultFetch() { export class PowerPointUrlImportService { private readonly requestFetch: typeof fetch; - private readonly maxFileSizeBytes: number; + private readonly maxFileSizeBytes: number | undefined; private readonly maxWarnings: number; constructor(private readonly options: PowerPointUrlImportServiceOptions) { this.requestFetch = options.fetch ?? getDefaultFetch(); - this.maxFileSizeBytes = options.maxFileSizeBytes ?? defaultMaxFileSizeBytes; + this.maxFileSizeBytes = options.maxFileSizeBytes; this.maxWarnings = options.maxWarnings ?? defaultMaxWarnings; - if (!Number.isSafeInteger(this.maxFileSizeBytes) || this.maxFileSizeBytes < 1) { + if ( + this.maxFileSizeBytes !== undefined && + (!Number.isSafeInteger(this.maxFileSizeBytes) || this.maxFileSizeBytes < 1) + ) { throw new Error('maxFileSizeBytes must be a positive safe integer.'); } if (!Number.isSafeInteger(this.maxWarnings) || this.maxWarnings < 1) { @@ -251,7 +257,7 @@ export class PowerPointUrlImportService { const contentType = validateContentType(response); const fileName = resolveFileName(input, url, response); - const download = await readBoundedResponse(response, this.maxFileSizeBytes, report); + const download = await readResponseBytes(response, this.maxFileSizeBytes, report); const file = new File(download.bytes, fileName, { type: contentType === 'application/octet-stream' ? powerPointMimeType : contentType, }); diff --git a/apps/editor/src/services/automation/presentationPublishingCapability.ts b/apps/editor/src/services/automation/presentationPublishingCapability.ts deleted file mode 100644 index 210bdacf..00000000 --- a/apps/editor/src/services/automation/presentationPublishingCapability.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { collectReferencedAssetIds } from '../../domain/assets/assetUsage'; -import type { ProjectDocument, TranscriptRecording } from '../../domain/documents/model'; -import type { - MirrorState, - MirrorSyncProgress, - ProjectRepository, - ShareMetadata, - SharePublishProgress, -} from '../contracts/interfaces'; -import type { AuthoringOperationProgress } from './authoringOperationRegistry'; -import { authoringRevision } from './getAuthoringSlideRevision'; - -const PUBLISH_LIMITS = { - contextSlides: 50, - fonts: 50, - media: 100, - text: 500, - warnings: 20, -} as const; - -export interface PresentationPublishSnapshot { - project: ProjectDocument; - revision: string; -} - -export interface PresentationPublishProgress extends AuthoringOperationProgress { - stage: 'assets' | 'completed' | 'pointer' | 'preparing' | 'warnings'; -} - -export interface PresentationPublishInput { - shareId?: string | undefined; - expectedRevision?: string | undefined; -} - -export interface PresentationPublishContext { - fonts: Array<{ family: string; source: string }>; - recordings: Array<{ - recordingId: string; - language: string; - rawAudioIncluded: boolean; - transcriptSegmentCount: number; - }>; - slides: Array<{ - description?: string | undefined; - descriptionFreshness: 'fresh' | 'missing' | 'stale'; - descriptionLanguage?: string | undefined; - slideId: string; - slideNumber: number; - }>; - truncated: boolean; -} - -export interface PresentationPublishMediaItem { - assetId: string; - fileName?: string | undefined; - kind: 'gif' | 'image' | 'recording' | 'video'; - mimeType: string; -} - -export interface PresentationPublishResult { - shareId: string; - publicUrl: string; - embedUrl: string; - revision: string; - context: PresentationPublishContext; - mediaManifest: PresentationPublishMediaItem[]; - mediaManifestTruncated: boolean; - warnings: string[]; -} - -export interface PresentationPublishingMirror { - loadConfig(): TConfig | null; - syncProject( - project: ProjectDocument, - repository: ProjectRepository, - config: TConfig, - options?: { onProgress?: (progress: MirrorSyncProgress) => void }, - ): Promise; -} - -export interface PresentationPublishingShare { - createShare( - project: ProjectDocument, - options?: { onProgress?: (progress: SharePublishProgress) => void }, - ): Promise; - updateShare( - shareId: string, - project: ProjectDocument, - options?: { onProgress?: (progress: SharePublishProgress) => void }, - ): Promise; -} - -export interface PresentationPublishingCapabilityOptions { - getSnapshot(): PresentationPublishSnapshot; - isRawRecordingAuthorized?: ((recording: TranscriptRecording) => boolean) | undefined; - mirror: PresentationPublishingMirror; - repository: ProjectRepository; - share: PresentationPublishingShare; -} - -function cloneProject(project: ProjectDocument): ProjectDocument { - return structuredClone(project); -} - -function boundedText(value: string) { - return value.trim().slice(0, PUBLISH_LIMITS.text); -} - -function boundedWarnings(warnings: string[]) { - return warnings.slice(0, PUBLISH_LIMITS.warnings).map((warning) => boundedText(warning)); -} - -function validateShareId(shareId: string | undefined) { - if (shareId === undefined) return; - if (!/^[a-zA-Z0-9_-]{1,128}$/.test(shareId)) { - throw new Error('Share ID must contain 1-128 letters, numbers, underscores, or hyphens.'); - } -} - -function getDescriptionFreshness(project: ProjectDocument, pageIndex: number) { - const page = project.pages[pageIndex]; - const description = page?.semanticDescription; - if (!description) return 'missing' as const; - return description.stale || - description.sourceRevision !== authoringRevision.getSlide(project, page.id) - ? ('stale' as const) - : ('fresh' as const); -} - -function createPublicRecording( - recording: TranscriptRecording, - isRawAudioAuthorized: boolean, -): TranscriptRecording { - if (isRawAudioAuthorized) return recording; - return { - ...recording, - audio: { mimeType: recording.audio.mimeType }, - }; -} - -function getRecordingHasPublishableAudio(recording: TranscriptRecording) { - return Boolean( - recording.audio.objectUrl || - recording.audio.fileName || - recording.audio.storage === 'file' || - recording.audio.storage === 'remote', - ); -} - -function progressBetween(current: number, total: number, start: number, length: number) { - if (total <= 0) return start; - return Math.min(start + length, start + Math.round((current / total) * length)); -} - -export class PresentationPublishingCapability { - constructor(private readonly options: PresentationPublishingCapabilityOptions) {} - - async publish( - input: PresentationPublishInput, - report: (progress: PresentationPublishProgress) => void, - ): Promise { - validateShareId(input.shareId); - const config = this.options.mirror.loadConfig(); - if (!config) { - throw new Error('Public sharing requires configured remote storage.'); - } - - const snapshot = this.options.getSnapshot(); - if (input.expectedRevision && input.expectedRevision !== snapshot.revision) { - throw new Error( - 'The requested presentation revision is stale. Read the current state and retry.', - ); - } - - report({ stage: 'preparing', progress: 5, detail: 'Preparing exact presentation revision' }); - const warnings: string[] = []; - const project = cloneProject(snapshot.project); - this.applyRecordingAuthorization(project, warnings); - - report({ stage: 'assets', progress: 10, detail: 'Publishing presentation assets' }); - let mirrorState: MirrorState; - try { - mirrorState = await this.options.mirror.syncProject( - project, - this.options.repository, - config, - { - onProgress: (progress) => - report({ - stage: 'assets', - progress: progressBetween(progress.current, progress.total, 10, 60), - loadedBytes: progress.current, - totalBytes: progress.total, - detail: boundedText(progress.label), - }), - }, - ); - } catch { - throw new Error('Could not publish presentation assets to configured remote storage.'); - } - if (mirrorState.status !== 'synced') { - throw new Error('Could not publish presentation assets to configured remote storage.'); - } - - if (this.options.getSnapshot().revision !== snapshot.revision) { - throw new Error( - 'The presentation changed while publishing assets. Read the current state and retry.', - ); - } - - if (warnings.length > 0) { - report({ - stage: 'warnings', - progress: 75, - detail: 'Publishing with bounded media warnings', - warnings: boundedWarnings(warnings), - }); - } - - report({ stage: 'pointer', progress: 80, detail: 'Publishing public share pointer' }); - let share: ShareMetadata; - try { - const options = { - authoringRevision: snapshot.revision, - onProgress: (progress: SharePublishProgress) => - report({ - stage: 'pointer', - progress: progressBetween(progress.current, progress.total, 80, 15), - current: progress.current, - total: progress.total, - detail: boundedText(progress.label), - }), - }; - share = input.shareId - ? await this.options.share.updateShare(input.shareId, project, options) - : await this.options.share.createShare(project, options); - } catch { - throw new Error('Could not publish the presentation share pointer.'); - } - - const result = this.createResult(project, snapshot.revision, share, warnings); - report({ stage: 'completed', progress: 100, detail: 'Published exact presentation revision' }); - return result; - } - - private applyRecordingAuthorization(project: ProjectDocument, warnings: string[]) { - if (!project.recordings) return; - for (const [recordingId, recording] of Object.entries(project.recordings)) { - const authorized = this.options.isRawRecordingAuthorized?.(recording) ?? false; - project.recordings[recordingId] = createPublicRecording(recording, authorized); - if (authorized && !getRecordingHasPublishableAudio(recording)) { - warnings.push(`Authorized recording ${recordingId} has no publishable raw audio.`); - } - } - } - - private createResult( - project: ProjectDocument, - revision: string, - share: ShareMetadata, - warnings: string[], - ): PresentationPublishResult { - const media = this.createMediaManifest(project); - return { - shareId: share.shareId, - publicUrl: share.publicUrl, - embedUrl: share.embedUrl, - revision, - context: this.createContext(project), - mediaManifest: media.slice(0, PUBLISH_LIMITS.media), - mediaManifestTruncated: media.length > PUBLISH_LIMITS.media, - warnings: boundedWarnings(warnings), - }; - } - - private createContext(project: ProjectDocument): PresentationPublishContext { - const recordings = Object.values(project.recordings ?? {}); - const fonts = Object.values(project.fonts ?? {}); - const slides = project.pages; - return { - fonts: fonts.slice(0, PUBLISH_LIMITS.fonts).map((font) => ({ - family: boundedText(font.family), - source: font.source, - })), - recordings: recordings.slice(0, PUBLISH_LIMITS.contextSlides).map((recording) => ({ - recordingId: recording.id, - language: boundedText(recording.language ?? 'und'), - rawAudioIncluded: getRecordingHasPublishableAudio(recording), - transcriptSegmentCount: recording.segments.length, - })), - slides: slides.slice(0, PUBLISH_LIMITS.contextSlides).map((page, index) => ({ - slideId: page.id, - slideNumber: index + 1, - descriptionFreshness: getDescriptionFreshness(project, index), - ...(page.semanticDescription - ? { - description: boundedText(page.semanticDescription.text), - descriptionLanguage: boundedText(page.semanticDescription.language), - } - : {}), - })), - truncated: - fonts.length > PUBLISH_LIMITS.fonts || - recordings.length > PUBLISH_LIMITS.contextSlides || - slides.length > PUBLISH_LIMITS.contextSlides, - }; - } - - private createMediaManifest(project: ProjectDocument): PresentationPublishMediaItem[] { - const referencedAssetIds = collectReferencedAssetIds(project); - const assets = Object.values(project.assets) - .filter((asset) => referencedAssetIds.has(asset.id)) - .map((asset) => ({ - assetId: asset.id, - kind: asset.type, - mimeType: asset.mimeType, - ...(asset.fileName ? { fileName: asset.fileName } : {}), - })); - const recordings = Object.values(project.recordings ?? {}) - .filter(getRecordingHasPublishableAudio) - .map((recording) => ({ - assetId: recording.id, - kind: 'recording' as const, - mimeType: recording.audio.mimeType, - ...(recording.audio.fileName ? { fileName: recording.audio.fileName } : {}), - })); - return [...assets, ...recordings]; - } -} diff --git a/apps/editor/src/services/exporting/pdfDocumentBuilder.ts b/apps/editor/src/services/exporting/pdfDocumentBuilder.ts new file mode 100644 index 00000000..930f0df6 --- /dev/null +++ b/apps/editor/src/services/exporting/pdfDocumentBuilder.ts @@ -0,0 +1,32 @@ +import { jsPDF } from 'jspdf'; +import type { PdfExportPage } from './pdfExportService'; + +function getPageFormat(page: PdfExportPage): [number, number] { + return [page.widthPoints, page.heightPoints]; +} + +function createArrayBuffer( + pages: PdfExportPage[], + onProgress?: (current: number, total: number) => void, +) { + const firstPage = pages[0]; + if (!firstPage) throw new Error('A PDF export needs at least one slide.'); + const firstFormat = getPageFormat(firstPage); + const document = new jsPDF({ + compress: true, + format: firstFormat, + orientation: firstPage.widthPoints >= firstPage.heightPoints ? 'landscape' : 'portrait', + unit: 'pt', + }); + pages.forEach((page, index) => { + const format = getPageFormat(page); + if (index > 0) { + document.addPage(format, page.widthPoints >= page.heightPoints ? 'landscape' : 'portrait'); + } + document.addImage(page.bytes, 'PNG', 0, 0, format[0], format[1], undefined, 'FAST'); + onProgress?.(index + 1, pages.length); + }); + return document.output('arraybuffer'); +} + +export const pdfDocumentBuilder = { createArrayBuffer }; diff --git a/apps/editor/src/services/exporting/pdfExport.worker.ts b/apps/editor/src/services/exporting/pdfExport.worker.ts new file mode 100644 index 00000000..48987e14 --- /dev/null +++ b/apps/editor/src/services/exporting/pdfExport.worker.ts @@ -0,0 +1,28 @@ +import { pdfDocumentBuilder } from './pdfDocumentBuilder'; +import type { + PdfExportWorkerRequest, + PdfExportWorkerResponse, +} from './pdfExportWorkerProtocol'; + +const workerScope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: PdfExportWorkerResponse, transfer?: Transferable[]): void; +}; + +workerScope.onmessage = (event) => { + try { + const bytes = pdfDocumentBuilder.createArrayBuffer(event.data.pages, (current, total) => { + workerScope.postMessage({ + current, + total, + type: 'progress', + } satisfies PdfExportWorkerResponse); + }); + workerScope.postMessage({ bytes, type: 'result' }, [bytes]); + } catch (error) { + workerScope.postMessage({ + message: error instanceof Error ? error.message : 'PDF export failed.', + type: 'error', + } satisfies PdfExportWorkerResponse); + } +}; diff --git a/apps/editor/src/services/exporting/pdfExportService.ts b/apps/editor/src/services/exporting/pdfExportService.ts index 4408519e..f3f13873 100644 --- a/apps/editor/src/services/exporting/pdfExportService.ts +++ b/apps/editor/src/services/exporting/pdfExportService.ts @@ -1,38 +1,60 @@ -import { jsPDF } from 'jspdf'; +import type { + PdfExportPage, + PdfExportWorkerRequest, + PdfExportWorkerResponse, +} from './pdfExportWorkerProtocol'; -export interface PdfExportPage { - bytes: Uint8Array; - heightPoints: number; - widthPoints: number; -} +export type { PdfExportPage } from './pdfExportWorkerProtocol'; -function getPageFormat(page: PdfExportPage): [number, number] { - return [page.widthPoints, page.heightPoints]; +function createPdfWorker() { + if (typeof Worker === 'undefined') throw new Error('Web workers are required for PDF export.'); + return new Worker(new URL('./pdfExport.worker.ts', import.meta.url), { type: 'module' }); } export const pdfExportService = { - createBlob(pages: PdfExportPage[]) { - const firstPage = pages[0]; - if (!firstPage) throw new Error('A PDF export needs at least one slide.'); - const firstFormat = getPageFormat(firstPage); - const document = new jsPDF({ - compress: true, - format: firstFormat, - orientation: firstPage.widthPoints >= firstPage.heightPoints ? 'landscape' : 'portrait', - unit: 'pt', - }); - - pages.forEach((page, index) => { - const format = getPageFormat(page); - if (index > 0) { - document.addPage( - format, - page.widthPoints >= page.heightPoints ? 'landscape' : 'portrait', - ); + createBlob( + pages: PdfExportPage[], + options: { onProgress?: (current: number, total: number) => void } = {}, + ) { + if (!pages.length) return Promise.reject(new Error('A PDF export needs at least one slide.')); + return new Promise((resolve, reject) => { + let worker: Worker; + try { + worker = createPdfWorker(); + } catch (error) { + reject(error instanceof Error ? error : new Error('PDF export worker is unavailable.')); + return; + } + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + if (response.type === 'progress') { + options.onProgress?.(response.current, response.total); + return; + } + worker.terminate(); + if (response.type === 'error') { + reject(new Error(response.message)); + return; + } + resolve(new Blob([response.bytes], { type: 'application/pdf' })); + }; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message || 'PDF export worker failed.')); + }; + try { + const transfer = [ + ...new Set( + pages + .map((page) => page.bytes.buffer) + .filter((buffer): buffer is ArrayBuffer => buffer instanceof ArrayBuffer), + ), + ]; + worker.postMessage({ pages, type: 'create-pdf' } satisfies PdfExportWorkerRequest, transfer); + } catch (error) { + worker.terminate(); + reject(error instanceof Error ? error : new Error('PDF export could not be started.')); } - document.addImage(page.bytes, 'PNG', 0, 0, format[0], format[1], undefined, 'FAST'); }); - - return document.output('blob'); }, }; diff --git a/apps/editor/src/services/exporting/pdfExportWorkerProtocol.ts b/apps/editor/src/services/exporting/pdfExportWorkerProtocol.ts new file mode 100644 index 00000000..56feb77d --- /dev/null +++ b/apps/editor/src/services/exporting/pdfExportWorkerProtocol.ts @@ -0,0 +1,15 @@ +export interface PdfExportPage { + bytes: Uint8Array; + heightPoints: number; + widthPoints: number; +} + +export interface PdfExportWorkerRequest { + pages: PdfExportPage[]; + type: 'create-pdf'; +} + +export type PdfExportWorkerResponse = + | { current: number; total: number; type: 'progress' } + | { bytes: ArrayBuffer; type: 'result' } + | { message: string; type: 'error' }; diff --git a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts index 398f9272..8edb7aa6 100644 --- a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts +++ b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts @@ -407,30 +407,6 @@ export class WebMcpToolAdapter { : {}), }), }, - { - 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: 128 }, - expectedRevision: { type: 'string', minLength: 1, maxLength: 200 }, - }, - }, - execute: (input) => - this.controller.publishPresentation({ - ...(optionalStringInput(input, 'shareId') - ? { shareId: optionalStringInput(input, 'shareId') } - : {}), - ...(optionalStringInput(input, 'expectedRevision') - ? { expectedRevision: optionalStringInput(input, 'expectedRevision') } - : {}), - }), - }, { name: 'get_operation_status', title: 'Get authoring operation status', diff --git a/apps/editor/src/ui/editor/shell/EditorShell.tsx b/apps/editor/src/ui/editor/shell/EditorShell.tsx index 81560283..60e8a6c1 100644 --- a/apps/editor/src/ui/editor/shell/EditorShell.tsx +++ b/apps/editor/src/ui/editor/shell/EditorShell.tsx @@ -11,6 +11,7 @@ import { authoringAutomationController, type AuthoringProgressReporter, } from '../../../services/automation/authoringAutomationController'; +import type { AuthoringOperationStatus } from '../../../services/automation/authoringOperationRegistry'; import { createAuthoringVisualCapability, type AuthoringExportInput, @@ -22,7 +23,6 @@ import { deckLocalizationCapability } from '../../../services/automation/deckLoc import { authoringRevision } from '../../../services/automation/getAuthoringSlideRevision'; import { localSlideDescriptionGenerator } from '../../../services/automation/localSlideDescriptionGenerator'; import { PowerPointUrlImportService } from '../../../services/automation/powerPointUrlImportService'; -import { PresentationPublishingCapability } from '../../../services/automation/presentationPublishingCapability'; import { imageGenerationModel } from '../../../services/image-generation/imageGenerationModel'; import { WebMcpToolAdapter, @@ -529,7 +529,15 @@ function EditorDesktopShell({ services }: EditorShellProps) { }); const { pdfExportService } = await import('../../../services/exporting/pdfExportService'); return { - blob: pdfExportService.createBlob(pages), + blob: await pdfExportService.createBlob(pages, { + onProgress: (current, total) => + report({ + stage: 'assembling-pdf', + progress: 85 + Math.round((current / total) * 10), + current, + total, + }), + }), frameCount: renderedFrames.length, slideCount: project.pages.length, warnings: [], @@ -652,7 +660,18 @@ function EditorDesktopShell({ services }: EditorShellProps) { '../../../services/exporting/pdfExportService' ); services.exportService.downloadBlob( - pdfExportService.createBlob(pages), + await pdfExportService.createBlob(pages, { + onProgress: (current, total) => + showImageExportNotice( + { + detail: `Assembling ${current} of ${total}`, + message: 'Exporting PDF...', + progress: { current, total }, + tone: 'info', + }, + { persistent: true }, + ), + }), services.exportService.getPdfFileName(vm.project), ); showImageExportNotice({ @@ -1630,6 +1649,7 @@ function EditorDesktopShell({ services }: EditorShellProps) { useEffect(() => { if (!editorShellBrowserUtils.isWebMcpProtocolEnabled()) return undefined; + const operationKinds = new Map(); const getProject = () => automationDelegateRef.current.getState().project; const applyProject = (project: ProjectDocument, activePageId?: string) => authoringVmRef.current.applyProjectForAutomation(project, activePageId); @@ -1687,16 +1707,6 @@ function EditorDesktopShell({ services }: EditorShellProps) { getActivePageId: () => authoringVmRef.current.activePageId, getProject: () => automationDelegateRef.current.getState().project, }); - const publishingCapability = new PresentationPublishingCapability({ - getSnapshot: () => { - const project = getProject(); - return { project, revision: authoringRevision.getPresentation(project) }; - }, - mirror: services.mirrorService, - repository: services.projectRepository, - share: services.shareService, - isRawRecordingAuthorized: (recording) => recording.audio.publicShareAuthorized === true, - }); const delegate = createAuthoringAutomationDelegate({ assetCapabilities, deckLocalization, @@ -1705,11 +1715,24 @@ function EditorDesktopShell({ services }: EditorShellProps) { replaceProject: (project) => authoringVmRef.current.replaceProjectForAutomation(project), applyProject, powerPointUrlImportService, - publishingCapability, visualCapability, }); + const handleOperationStatusChange = (status: AuthoringOperationStatus) => { + if (status.state === 'queued') { + operationKinds.set(status.operationId, status.stage); + } + const operationKind = operationKinds.get(status.operationId); + if (operationKind) { + authoringVmRef.current.updateAuthoringOperationProgress(operationKind, status); + } + if (status.state === 'completed' || status.state === 'failed') { + operationKinds.delete(status.operationId); + } + }; const adapter = new WebMcpToolAdapter( - new authoringAutomationController.AuthoringAutomationController(delegate), + new authoringAutomationController.AuthoringAutomationController(delegate, { + onOperationStatusChange: handleOperationStatusChange, + }), ); const demoWindow = window as WebMcpDemoWindow; const modelContext = editorShellBrowserUtils.getWebMcpModelContext(); @@ -1719,6 +1742,7 @@ function EditorDesktopShell({ services }: EditorShellProps) { return () => { unregister?.(); delete demoWindow.localStudioWebMcpTools; + operationKinds.clear(); }; }, [services]); diff --git a/apps/editor/src/ui/editor/state/authoringOperationUiProgress.ts b/apps/editor/src/ui/editor/state/authoringOperationUiProgress.ts new file mode 100644 index 00000000..a45e4f74 --- /dev/null +++ b/apps/editor/src/ui/editor/state/authoringOperationUiProgress.ts @@ -0,0 +1,124 @@ +import type { ProjectDocument } from '../../../domain/documents/model'; +import type { AuthoringOperationStatus } from '../../../services/automation/authoringOperationRegistry'; +import type { OperationNoticeState } from './use-operation-notice'; + +export interface DeckTranslationProgressState { + activePageIds: string[]; + completedPages: number; + currentPageName: string; + totalPages: number; +} + +export interface PresentationImportProgressState { + detail: string; + progress: number; + stage: + | 'reading' + | 'inspecting' + | 'extracting-objects' + | 'downloading-fonts' + | 'extracting-media' + | 'mapping-animations' + | 'opening'; + title: string; +} + +interface AuthoringOperationProgressActions { + setDeckTranslationProgress(progress: DeckTranslationProgressState | undefined): void; + setIsTranslating(active: boolean): void; + setPresentationImportProgress(progress: PresentationImportProgressState | undefined): void; + showOperationNotice( + notice: OperationNoticeState | undefined, + options?: { persistent?: boolean }, + ): void; +} + +interface AuthoringOperationUiProgressInput { + operationKind: string; + project: ProjectDocument; + status: AuthoringOperationStatus; +} + +type ProgressSurface = 'import' | 'notice' | 'translation'; + +const operationPresentation: Record = { + 'describing-slides': { message: 'Generating detailed descriptions', surface: 'notice' }, + 'exporting-presentation': { message: 'Exporting presentation', surface: 'notice' }, + 'fetching-powerpoint': { message: 'PowerPoint import', surface: 'import' }, + 'generating-image': { message: 'Generating image', surface: 'notice' }, + 'preparing-ai-models': { message: 'Preparing AI models', surface: 'notice' }, + 'preparing-translation': { message: 'Deck translation', surface: 'translation' }, +}; + +const importStages: Record = { + 'fetching-powerpoint': 'reading', + 'downloading-powerpoint': 'reading', + 'importing-package': 'inspecting', + 'extracting-objects': 'extracting-objects', + 'resolving-fonts': 'downloading-fonts', + 'opening-presentation': 'opening', +}; + +function isActive(status: AuthoringOperationStatus) { + return status.state === 'queued' || status.state === 'running'; +} + +function getTerminalNotice( + status: AuthoringOperationStatus, + message: string, +): OperationNoticeState | undefined { + if (status.state === 'completed') return { message: `${message} completed`, tone: 'success' }; + if (status.state !== 'failed') return undefined; + return { detail: status.error, message: `${message} failed`, tone: 'error' }; +} + +export const authoringOperationUiProgress = { + update( + { operationKind, project, status }: AuthoringOperationUiProgressInput, + actions: AuthoringOperationProgressActions, + ) { + const presentation = operationPresentation[operationKind] ?? { + message: 'WebMCP operation', + surface: 'notice' as const, + }; + if (!isActive(status)) { + if (presentation.surface === 'translation') { + actions.setDeckTranslationProgress(undefined); + actions.setIsTranslating(false); + } + if (presentation.surface === 'import') actions.setPresentationImportProgress(undefined); + actions.showOperationNotice(getTerminalNotice(status, presentation.message)); + return; + } + if (presentation.surface === 'translation') { + const totalPages = status.total ?? project.pages.length; + const currentPage = project.pages.find((page) => page.name === status.detail); + actions.setIsTranslating(true); + actions.setDeckTranslationProgress({ + activePageIds: currentPage ? [currentPage.id] : [], + completedPages: Math.min(status.current ?? 0, totalPages), + currentPageName: status.detail || 'Preparing slides', + totalPages, + }); + return; + } + if (presentation.surface === 'import') { + actions.setPresentationImportProgress({ + detail: status.detail || 'Preparing the PowerPoint presentation.', + progress: status.percentage, + stage: importStages[status.stage] ?? 'reading', + title: 'Importing PowerPoint', + }); + return; + } + actions.showOperationNotice( + { + detail: status.detail, + message: presentation.message, + progress: { current: status.percentage, total: 100 }, + tone: 'info', + }, + { persistent: true }, + ); + }, +}; diff --git a/apps/editor/src/ui/editor/state/useEditorViewModel.ts b/apps/editor/src/ui/editor/state/useEditorViewModel.ts index d0c36220..55210b9d 100644 --- a/apps/editor/src/ui/editor/state/useEditorViewModel.ts +++ b/apps/editor/src/ui/editor/state/useEditorViewModel.ts @@ -25,6 +25,7 @@ import type { import { sampleProject } from '../../../domain/projects/sampleProject'; import { analyticsModelProperties } from '../../../services/analytics/analyticsModelProperties'; import type { EditorAutomationDelegate } from '../../../services/automation/editorAutomationController'; +import type { AuthoringOperationStatus } from '../../../services/automation/authoringOperationRegistry'; import type { AiProviderState, FontCatalogItem, @@ -59,6 +60,11 @@ import { powerPointIo } from './power-point-io'; import { textTranslationLayout } from './text-translation-layout'; import type { TranslationPatch } from './text-translation-layout'; import { useOperationNotice } from './use-operation-notice'; +import { + authoringOperationUiProgress, + type DeckTranslationProgressState, + type PresentationImportProgressState, +} from './authoringOperationUiProgress'; import { useStockMediaLibrary } from './use-stock-media-library'; import { editorViewModelProgress } from './editorViewModelProgress'; import { editorViewModelProject } from './editorViewModelProject'; @@ -89,6 +95,10 @@ export type RightPanelTab = | 'animations'; export type TextPreset = 'title' | 'subtitle' | 'body'; export type { OperationNoticeState } from './use-operation-notice'; +export type { + DeckTranslationProgressState, + PresentationImportProgressState, +} from './authoringOperationUiProgress'; export interface MissingPowerPointFont { family: string; @@ -107,20 +117,6 @@ interface PromptPreparationState extends ModelDownloadProgressDetails { status: 'idle' | 'downloading' | 'ready' | 'failed'; } -export interface PresentationImportProgressState { - detail: string; - progress: number; - stage: - | 'reading' - | 'inspecting' - | 'extracting-objects' - | 'downloading-fonts' - | 'extracting-media' - | 'mapping-animations' - | 'opening'; - title: string; -} - export type RemoteImportStatus = | 'loading' | 'ready' @@ -207,13 +203,6 @@ function getMissingPowerPointFonts(project: ProjectDocument, missingFamilies: st return Array.from(fonts.values()).sort((a, b) => a.family.localeCompare(b.family)); } -interface DeckTranslationProgressState { - activePageIds: string[]; - completedPages: number; - currentPageName: string; - totalPages: number; -} - export function useEditorViewModel(services: AppServices) { const initialProject = useMemo( () => editorViewModelProject.normalizeProjectDocument(services.initialProject), @@ -731,6 +720,21 @@ export function useEditorViewModel(services: AppServices) { }); } + function updateAuthoringOperationProgress( + operationKind: string, + status: AuthoringOperationStatus, + ) { + authoringOperationUiProgress.update( + { operationKind, project: projectRef.current, status }, + { + setDeckTranslationProgress, + setIsTranslating, + setPresentationImportProgress, + showOperationNotice, + }, + ); + } + async function downloadRequiredModels() { setModelStates((currentStates) => currentStates.map((state) => @@ -3605,6 +3609,7 @@ export function useEditorViewModel(services: AppServices) { automation, applyProjectForAutomation, replaceProjectForAutomation, + updateAuthoringOperationProgress, activePageId, activePageFocusKey, zoomPercent, diff --git a/apps/editor/src/ui/styles.css b/apps/editor/src/ui/styles.css index 5fcfaca4..aed71e13 100644 --- a/apps/editor/src/ui/styles.css +++ b/apps/editor/src/ui/styles.css @@ -6,7 +6,6 @@ @import './styles/webmcp.css'; @import './styles/webmcp-tools.css'; @import './styles/webmcp-workflow.css'; -@import './styles/webmcp-results.css'; @import './styles/shell.css'; @import './styles/editor-mobile-unavailable.css'; @import './styles/presentation-import.css'; diff --git a/apps/editor/src/ui/styles/webmcp-results.css b/apps/editor/src/ui/styles/webmcp-results.css deleted file mode 100644 index e127d6f8..00000000 --- a/apps/editor/src/ui/styles/webmcp-results.css +++ /dev/null @@ -1,51 +0,0 @@ -.webmcp-result-panel { - min-height: 0; - display: grid; - grid-template-rows: 34px minmax(160px, 1fr); - border: 1px solid rgba(122, 255, 199, 0.16); - border-radius: 6px; - background: rgba(0, 0, 0, 0.26); - overflow: hidden; -} - -.webmcp-result-heading { - display: flex; - align-items: center; - gap: 8px; - padding: 0 12px; - border-bottom: 1px solid rgba(122, 255, 199, 0.12); - color: #9fffd2; - font-size: 12px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.webmcp-result-panel pre { - margin: 0; - padding: 12px; - overflow: auto; - color: rgba(238, 250, 245, 0.82); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; - font-size: 12px; - line-height: 18px; - white-space: pre-wrap; -} - -.webmcp-editor-frame { - min-width: 0; - min-height: 100vh; - padding: 18px; - background: - linear-gradient(135deg, rgba(159, 255, 210, 0.08), transparent 30%), rgba(3, 5, 6, 0.88); -} - -.webmcp-editor-frame iframe { - width: 100%; - height: calc(100vh - 36px); - display: block; - border: 1px solid rgba(159, 255, 210, 0.2); - border-radius: 8px; - background: #050d10; - box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45); -} diff --git a/apps/editor/src/ui/styles/webmcp-workflow.css b/apps/editor/src/ui/styles/webmcp-workflow.css index a44efdc4..46a53eee 100644 --- a/apps/editor/src/ui/styles/webmcp-workflow.css +++ b/apps/editor/src/ui/styles/webmcp-workflow.css @@ -1,4 +1,54 @@ .webmcp-workflow { + display: grid; + gap: 18px; +} + +.webmcp-workflow-section { + display: grid; + gap: 10px; +} + +.webmcp-workflow-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding-bottom: 9px; + border-bottom: 1px solid rgba(100, 183, 255, 0.18); +} + +.webmcp-workflow-section-heading h2 { + margin: 0; + color: #ffffff; + font-family: 'Orbitron', sans-serif; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 18px; +} + +.webmcp-workflow-section-heading p { + margin: 4px 0 0; + color: rgba(238, 250, 245, 0.56); + font-size: 11px; + line-height: 16px; +} + +.webmcp-workflow-section-heading > span { + min-width: 24px; + height: 20px; + display: inline-grid; + place-items: center; + border: 1px solid rgba(100, 183, 255, 0.22); + border-radius: 999px; + background: rgba(100, 183, 255, 0.09); + color: #bfe5ff; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + font-weight: 800; +} + +.webmcp-workflow-section-tools { display: grid; gap: 9px; } @@ -8,6 +58,77 @@ gap: 7px; } +.webmcp-step-body { + display: grid; + gap: 7px; +} + +.webmcp-step-options { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-left: 30px; +} + +.webmcp-step-option { + min-height: 30px; + padding: 5px 9px; + border: 1px solid rgba(100, 183, 255, 0.28); + border-radius: 6px; + cursor: pointer; + background: rgba(100, 183, 255, 0.08); + color: #bfe5ff; + font-size: 11px; + font-weight: 700; +} + +.webmcp-step-option:hover, +.webmcp-step-option[aria-pressed='true'] { + border-color: rgba(159, 255, 210, 0.62); + background: rgba(159, 255, 210, 0.14); + color: #9fffd2; +} + +.webmcp-step-status { + margin: 0 0 0 30px; + padding: 7px 10px; + border-left: 2px solid rgba(100, 183, 255, 0.52); + background: linear-gradient(90deg, rgba(100, 183, 255, 0.1), transparent 82%); + color: rgba(238, 250, 245, 0.76); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 16px; +} + +.webmcp-step-result { + max-height: 320px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid rgba(122, 255, 199, 0.16); + border-radius: 6px; + background: rgba(0, 0, 0, 0.26); + color: rgba(238, 250, 245, 0.82); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +.webmcp-step-result-shell { + margin-left: 30px; +} + +.webmcp-step-result-shell > summary { + margin-bottom: 7px; + cursor: pointer; + color: #9fffd2; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + .webmcp-step-button { justify-content: flex-start; width: 100%; @@ -19,7 +140,7 @@ text-align: left; } -.webmcp-step-button:hover:not(:disabled), +.webmcp-step-button:hover, .webmcp-step-button-active { border-color: rgba(159, 255, 210, 0.62); background: rgba(159, 255, 210, 0.1); @@ -31,18 +152,6 @@ box-shadow: 0 0 0 2px rgba(159, 255, 210, 0.14); } -.webmcp-step-index { - width: 22px; - height: 22px; - display: inline-grid; - place-items: center; - border-radius: 4px; - background: rgba(100, 183, 255, 0.16); - color: #bfe5ff; - font-size: 11px; - font-weight: 900; -} - .webmcp-step-command { display: grid; grid-template-columns: minmax(0, 1fr) 38px; diff --git a/apps/editor/src/ui/styles/webmcp.css b/apps/editor/src/ui/styles/webmcp.css index f7e483d1..06dd6b7d 100644 --- a/apps/editor/src/ui/styles/webmcp.css +++ b/apps/editor/src/ui/styles/webmcp.css @@ -1,21 +1,53 @@ .webmcp-page { - min-height: 100vh; + height: 100vh; + height: 100dvh; + min-height: 0; display: grid; grid-template-columns: minmax(380px, 520px) minmax(720px, 1fr); background: linear-gradient(90deg, rgba(46, 167, 255, 0.14), transparent 42%), linear-gradient(180deg, rgba(55, 253, 118, 0.08), transparent 36%), #05090a; color: #eefaf5; + overflow: hidden; } .webmcp-control-plane { - display: flex; - flex-direction: column; + max-height: 100vh; + max-height: 100dvh; + display: grid; + grid-auto-rows: max-content; + align-content: start; gap: 22px; min-width: 0; padding: 28px; border-right: 1px solid rgba(122, 255, 199, 0.18); background: rgba(5, 12, 13, 0.9); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.webmcp-editor-frame { + height: 100vh; + height: 100dvh; + min-width: 0; + min-height: 0; + padding: 18px; + background: + linear-gradient(135deg, rgba(159, 255, 210, 0.08), transparent 30%), rgba(3, 5, 6, 0.88); + overflow: hidden; +} + +.webmcp-editor-frame iframe { + width: 100%; + height: calc(100vh - 36px); + height: calc(100dvh - 36px); + display: block; + border: 1px solid rgba(159, 255, 210, 0.2); + border-radius: 8px; + background: #050d10; + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45); } .webmcp-brand-row { @@ -113,8 +145,7 @@ border-color: #9fffd2; } -.webmcp-primary-action:disabled, -.webmcp-step-button:disabled { +.webmcp-primary-action:disabled { cursor: not-allowed; opacity: 0.52; } diff --git a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx index 157ea930..f87c9be8 100644 --- a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx +++ b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx @@ -1,207 +1,28 @@ -import { Bot, FileJson, Play, Radar, SendHorizontal } from 'lucide-react'; -import { useMemo, useRef, useState } from 'react'; -import { type WebMcpShowcaseStep, webMcpShowcaseSteps } from './webMcpShowcaseSteps'; - -interface WebMcpToolLike { - call?: (input: Record) => unknown; - description?: string; - execute?: (input: Record) => unknown; - invoke?: (input: Record) => unknown; - name: string; -} - -interface BrowserModelContext { - executeTool?: (tool: WebMcpToolLike, inputArguments: string) => unknown; - getTools(options: { fromOrigins: string[] }): Promise; -} - -function getBrowserModelContext() { - if (typeof document === 'undefined') return undefined; - return (document as Document & { modelContext?: BrowserModelContext }).modelContext; -} - -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'; - }) - ); -} - -function getLocalDemoTools(iframe: HTMLIFrameElement) { - const frameWindow = iframe.contentWindow; - if (!frameWindow || !('localStudioWebMcpTools' in frameWindow)) return undefined; - const tools = frameWindow.localStudioWebMcpTools; - return isWebMcpToolLikeArray(tools) ? tools : undefined; -} - -async function waitForLocalDemoTools(iframe: HTMLIFrameElement) { - for (let attempt = 0; attempt < 20; attempt += 1) { - const tools = getLocalDemoTools(iframe); - if (tools?.length) return tools; - await new Promise((resolve) => window.setTimeout(resolve, 100)); - } - return undefined; -} - -async function callTool( - tool: WebMcpToolLike, - input: Record, - useProtocolExecution: boolean, -) { - const modelContext = useProtocolExecution ? getBrowserModelContext() : undefined; - if (modelContext?.executeTool) { - const result = await modelContext.executeTool(tool, JSON.stringify(input)); - if (typeof result !== 'string') return result; - try { - return JSON.parse(result) as unknown; - } catch { - return result; - } - } - const callable = tool.call ?? tool.execute ?? tool.invoke; - if (!callable) throw new Error(`${tool.name} is not callable in this WebMCP runtime.`); - return callable(input); -} - -function formatPayload(value: unknown) { - return JSON.stringify(value, null, 2); -} - -function getDefaultCommandValue(step: WebMcpShowcaseStep) { - const primaryValue = step.input.name; - return step.inputKind === 'name' && typeof primaryValue === 'string' - ? primaryValue - : formatPayload(step.input); -} - -function getCommandInput(step: WebMcpShowcaseStep, value: string) { - if (step.inputKind === 'name') return { name: value }; - return JSON.parse(value) as Record; -} - -function getOperationId(value: unknown) { - if (!value || typeof value !== 'object') return undefined; - const data = (value as { data?: unknown }).data; - if (!data || typeof data !== 'object') return undefined; - const operationId = (data as { operationId?: unknown }).operationId; - return typeof operationId === 'string' ? operationId : undefined; -} - -function getToolFailure(value: unknown) { - if (!value || typeof value !== 'object') return undefined; - const result = value as { errorCode?: unknown; message?: unknown; ok?: unknown }; - if (result.ok !== false) return undefined; - if (typeof result.message === 'string') return result.message; - if (typeof result.errorCode === 'string') return result.errorCode; - return 'The tool returned an unsuccessful result.'; -} +import { FileJson, Play, Radar, SendHorizontal } from 'lucide-react'; +import { webMcpShowcaseCatalog } from './webMcpShowcaseSteps'; +import { useWebMcpShowcase } from './useWebMcpShowcase'; export function WebMcpShowcasePage() { - const iframeRef = useRef(null); - const stepButtonRefs = useRef>({}); - const [tools, setTools] = useState([]); - const [status, setStatus] = useState('Ready to discover page tools.'); - const [lastResult, setLastResult] = useState('{}'); - const [isRunning, setIsRunning] = useState(false); - const [useProtocolExecution, setUseProtocolExecution] = useState(false); - const [activeStepName, setActiveStepName] = useState(); - const [focusedStepName, setFocusedStepName] = useState(); - const [commandValues, setCommandValues] = useState>(() => - Object.fromEntries( - webMcpShowcaseSteps.map((step) => [step.toolName, getDefaultCommandValue(step)]), - ), - ); - const editorSrc = '/editor/?webmcp=1&newProject=1'; - const toolsByName = useMemo(() => new Map(tools.map((tool) => [tool.name, tool])), [tools]); - - function openStep(step: WebMcpShowcaseStep) { - setActiveStepName(step.toolName); - setFocusedStepName(step.toolName); - } - - function focusStep(toolName: string) { - setActiveStepName(toolName); - setFocusedStepName(toolName); - stepButtonRefs.current[toolName]?.focus(); - window.requestAnimationFrame(() => { - const stepButton = stepButtonRefs.current[toolName]; - stepButton?.focus(); - stepButton?.scrollIntoView?.({ block: 'nearest' }); - }); - } - - async function discoverTools() { - const iframe = iframeRef.current; - if (!iframe) { - setStatus('Editor iframe is not ready yet.'); - setTools([]); - return; - } - - setStatus('Discovering tools from LocalStudio...'); - const modelContext = getBrowserModelContext(); - const iframeOrigin = new URL(iframe.src).origin; - const protocolTools = modelContext - ? await modelContext.getTools({ fromOrigins: [iframeOrigin] }) - : undefined; - const discoveredTools = protocolTools?.length - ? protocolTools - : await waitForLocalDemoTools(iframe); - const usedProtocolTools = Boolean(protocolTools?.length); - if (!discoveredTools) { - setStatus( - 'No WebMCP runtime or same-origin demo tools found. Wait for the editor frame, then try again.', - ); - setTools([]); - return; - } - setTools(discoveredTools); - setUseProtocolExecution(usedProtocolTools); - setStatus( - usedProtocolTools - ? `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 })), - ), - ); - } - - async function runStep(step: WebMcpShowcaseStep, commandValue: string) { - const tool = toolsByName.get(step.toolName); - if (!tool) { - setStatus(`${step.toolName} has not been discovered yet.`); - return; - } - - setIsRunning(true); - setStatus(`Running ${step.label}...`); - try { - const input = getCommandInput(step, commandValue); - const result = await callTool(tool, input, useProtocolExecution); - setLastResult(formatPayload(result)); - const failure = getToolFailure(result); - setStatus(failure ? `${step.label} failed: ${failure}` : `${step.label} completed.`); - const operationId = getOperationId(result); - if (operationId) { - setCommandValues((current) => ({ - ...current, - get_operation_status: formatPayload({ operationId, waitForChangeMs: 1000 }), - })); - } - } catch (error) { - setStatus( - `${step.label} failed: ${error instanceof Error ? error.message : 'Unknown error.'}`, - ); - } finally { - setIsRunning(false); - } - } + const { + actionResults, + actionStatuses, + activeStepName, + commandValues, + discoverTools, + discoveryStatus, + focusStep, + focusedStepName, + iframeRef, + isDiscovering, + isStepDisabled, + openStep, + runStep, + selectedOptionIds, + setCommandValue, + selectStepOption, + stepButtonRefs, + tools, + } = useWebMcpShowcase(); return (
@@ -224,7 +45,7 @@ export function WebMcpShowcasePage() {
- {status} + {discoveryStatus}
@@ -255,84 +76,147 @@ export function WebMcpShowcasePage() { )}
-
- {webMcpShowcaseSteps.map((step, index) => ( -
- - {activeStepName === step.toolName ? ( -
{ - event.preventDefault(); - void runStep(step, commandValues[step.toolName] ?? ''); - }} - > - {step.inputKind === 'name' ? ( - { - setCommandValues((current) => ({ - ...current, - [step.toolName]: event.target.value, - })); - }} - /> - ) : ( -