@lincyaw/workbench 0.1.0 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,480 @@
1
+ import type { ProjectOperation, ProjectPlan, ProjectPlanRequest, ProjectApply, ProjectApplied } from '@lincyaw/kernel'
2
+ import type { PreviewEventSubscription } from '@lincyaw/kernel'
3
+ import type { SpeechSettingsAnswer, SpeechSettingsChange } from '@lincyaw/kernel'
4
+ import type { AuthoringDiagnostics } from '@lincyaw/kernel'
5
+ import type { CompileJob, DiagnosticSnapshot, DocumentOperation, DocumentsAnswer, ErrorAnswer, FocusAnswer, FocusReport, IrAnswer, IrEvent, JobSource, JobsAnswer, OperationAnswer, PreviewAnswer, PreviewDiagnostics, PreviewRenderer, RevertResult, ReviewEnd, ReviewSnapshot, ReviewUpdate, RevisionsAnswer, SavedAnswer, SourceAnswer, SourceTransactionAnswer, SourceTransactionChange, StartJobRequest, UncommittedFile, WorkbenchAnswer } from '@lincyaw/kernel'
6
+ import type { ApprovalRequest } from '@lincyaw/kernel'
7
+ import type { OperationBatch } from './telemetry.ts'
8
+ import type { VoiceCatalogAnswer, VoiceCloneStatus, VoiceoverCancellation, VoiceoverJob, VoiceoverJobsAnswer, VoiceoverRequest, VoiceoverSnapshot, VoicePreviewAnswer, VoicePreviewQuery, VoicePreviewQuote, VoicePreviewRequest } from '@lincyaw/kernel'
9
+ import type { PageDeleteAnswer, PageDeletePlan, PageDeleteRequest } from '@lincyaw/kernel'
10
+ import type { DocumentAnswer, DocumentChange, DocumentChangeAnswer, DocumentEvent } from '@lincyaw/kernel'
11
+ import type { AppliedDocumentAuthoringAction, AuthoringCapabilities, DocumentAuthoringActionInspection, DocumentAuthoringActionPlan, DocumentAuthoringActionRead, DocumentAuthoringActionReference, DocumentAuthoringActionRequest } from '@lincyaw/kernel'
12
+ import type { DocumentRecordingAdoption, VerifiedDocumentRecordingAdoption, DocumentAudioImport, DocumentAudioImportAnswer, DocumentAudioInspectionAnswer, DocumentMediaClipChange } from '@lincyaw/kernel'
13
+ import type { DocumentImageImport, DocumentImageImportAnswer } from '@lincyaw/kernel'
14
+ import type { ProductionAssetAnswer, ProductionAssetQuery, ProductionMediaQuery, ProductionPlan, ProductionPlanQuery } from '@lincyaw/kernel'
15
+ import type { DocumentTranscriptionRequest, TranscriptionCapabilities, TranscriptionTask, TranscriptionListAnswer, TranscriptionRevision, TranscriptionCancellation, TranscriptionAdoptionDraft, TranscriptionAdoptionPlan, TranscriptionAdoptionApply, TranscriptionAdoptionAnswer } from '@lincyaw/kernel'
16
+
17
+ /** A change of the host's pending approvals that this author may answer. */
18
+ export type ApprovalEvent =
19
+ | { type: 'approval', id: string, request: ApprovalRequest }
20
+ | { type: 'settled', id: string }
21
+ /** The connection (re)opened: the pending approvals follow, so earlier ones are no longer known. */
22
+ | { type: 'reset' }
23
+
24
+ const observedFailures = new WeakSet<object>()
25
+ // A 503 with Retry-After is work still in progress (a preview being prepared), not a failure of the service.
26
+ const isRetryLater = (response: Response) => response.status === 503 && response.headers.has('Retry-After')
27
+ /** Whether the owned request observer already received this transport failure. */
28
+ export const isObservedWorkspaceFailure = (error: unknown): boolean => typeof error === 'object' && error !== null && observedFailures.has(error)
29
+
30
+ /** A route of the workspace server answered with an error. */
31
+ /** A cloned voice to train: the sample and the consent statement are WAV recordings of the same speaker. */
32
+ export interface VoiceCloneForm { engine?: string, voice: string, lang?: string, speaker: string, scope: string, sample: Blob, statement: Blob }
33
+
34
+ export class WorkspaceServerError extends Error {
35
+ readonly status: number
36
+ readonly answer: ErrorAnswer
37
+ constructor(status: number, answer: ErrorAnswer) {
38
+ super(answer.error ?? answer.detail ?? answer.title ?? `HTTP ${status}`)
39
+ this.status = status
40
+ this.answer = answer
41
+ }
42
+ }
43
+
44
+ /** Only transport metadata is observed; request content, headers and query values remain private. */
45
+ export interface WorkspaceRequestEvent {
46
+ kind: 'start' | 'failure' | 'recovery' | 'cancel'
47
+ requestId: number
48
+ path: string
49
+ method: string
50
+ document?: string
51
+ status?: number
52
+ /** The caller renders HTTP failures locally; transport failures remain global. */
53
+ feedbackOwner?: 'inline-preview'
54
+ }
55
+
56
+ /** The workbench's client of the workspace server's routes; each `subscribe*` returns its unsubscribe. */
57
+ export interface WorkspaceCapabilities {
58
+ /** Omitted capabilities retain the local workspace's complete behavior. */
59
+ diagnostics?: 'none' | 'full'
60
+ production?: boolean
61
+ narration?: boolean
62
+ export?: boolean
63
+ readOnly?: boolean
64
+ }
65
+
66
+ export interface WorkspaceClient {
67
+ readSpeechSettings?(signal?: AbortSignal): Promise<SpeechSettingsAnswer>
68
+ changeSpeechSettings?(change: SpeechSettingsChange, signal?: AbortSignal): Promise<SpeechSettingsAnswer>
69
+ openSpeechSettings?(): boolean
70
+ capabilities?: Readonly<WorkspaceCapabilities>
71
+ subscribePreviewEvents?: PreviewEventSubscription
72
+ openAuthoringClient?: AuthoringClientSubscription
73
+ subscribeRequests?(listener: (event: WorkspaceRequestEvent) => void): () => void
74
+ readDocuments(document?: string): Promise<DocumentsAnswer>
75
+ planProjectChange(request: ProjectPlanRequest): Promise<ProjectPlan>
76
+ operateDocument(operation: ProjectOperation, planned?: ProjectPlan): Promise<OperationAnswer>
77
+ readSource(document: string, path: string): Promise<SourceAnswer>
78
+ /** Saves the file; in a git workspace the save is committed at once, `message` its summary. */
79
+ saveSource(document: string, path: string, content: string, base: string, message?: string): Promise<SavedAnswer>
80
+ /** Saves a small set of files in one document with one version and rollback boundary. */
81
+ saveSourceTransaction(document: string, changes: SourceTransactionChange[], message?: string): Promise<SourceTransactionAnswer>
82
+ readIr(document: string): Promise<IrAnswer>
83
+ readDocument(document: string): Promise<DocumentAnswer>
84
+ readAuthoringCapabilities(document: string, signal?: AbortSignal): Promise<AuthoringCapabilities>
85
+ planDocumentAction(document: string, request: DocumentAuthoringActionRequest, signal?: AbortSignal): Promise<DocumentAuthoringActionPlan>
86
+ readDocumentActionPlan(document: string, reference: DocumentAuthoringActionRead, signal?: AbortSignal): Promise<DocumentAuthoringActionInspection>
87
+ applyDocumentAction(document: string, reference: DocumentAuthoringActionReference, signal?: AbortSignal): Promise<AppliedDocumentAuthoringAction>
88
+ /** A same-origin display URL; the canonical asset location remains untouched. */
89
+ documentAssetURL(document: string, assetId: string): string
90
+ changeDocument(document: string, change: DocumentChange): Promise<DocumentChangeAnswer>
91
+ importDocumentImage(document: string, file: File, change: DocumentImageImport, signal?: AbortSignal): Promise<DocumentImageImportAnswer>
92
+ readDocumentAudio(document: string, assetId: string, baseDocumentVersion: string, signal?: AbortSignal): Promise<DocumentAudioInspectionAnswer>
93
+ importDocumentAudio(document: string, file: File, change: DocumentAudioImport, signal?: AbortSignal): Promise<DocumentAudioImportAnswer>
94
+ adoptRecording(document: string, request: DocumentRecordingAdoption, signal?: AbortSignal): Promise<VerifiedDocumentRecordingAdoption>
95
+ changeDocumentMediaClip(document: string, change: DocumentMediaClipChange, signal?: AbortSignal): Promise<DocumentChangeAnswer>
96
+ readTranscriptionCapabilities(document: string, signal?: AbortSignal): Promise<TranscriptionCapabilities>
97
+ readTranscriptions(document: string, signal?: AbortSignal): Promise<TranscriptionListAnswer>
98
+ startTranscription(document: string, request: DocumentTranscriptionRequest, signal?: AbortSignal): Promise<TranscriptionTask>
99
+ readTranscription(document: string, query: { id: string } | { intent: string }, signal?: AbortSignal): Promise<TranscriptionTask>
100
+ readTranscriptionResult(document: string, id: string, revision: string, signal?: AbortSignal): Promise<TranscriptionRevision>
101
+ cancelTranscription(document: string, query: { id: string } | { intent: string }, signal?: AbortSignal): Promise<TranscriptionCancellation>
102
+ planTranscriptionAdoption(document: string, draft: TranscriptionAdoptionDraft, signal?: AbortSignal): Promise<TranscriptionAdoptionPlan>
103
+ applyTranscriptionAdoption(document: string, request: TranscriptionAdoptionApply, signal?: AbortSignal): Promise<TranscriptionAdoptionAnswer>
104
+ subscribeDocument(document: string, changed: (event: DocumentEvent) => void, failed?: () => void): () => void
105
+ readPageDeletion(document: string, section: string): Promise<PageDeletePlan>
106
+ deletePage(document: string, request: PageDeleteRequest): Promise<PageDeleteAnswer>
107
+ readVoiceCatalog(document: string, engine?: string, lang?: string, signal?: AbortSignal): Promise<VoiceCatalogAnswer>
108
+ /** Registers the speaker's consent and starts training a cloned voice: the owner's, and paid for at the provider. */
109
+ cloneVoice(document: string, request: VoiceCloneForm, signal?: AbortSignal): Promise<VoiceCloneStatus>
110
+ readCloneStatus(document: string, voice: string, engine?: string, signal?: AbortSignal): Promise<VoiceCloneStatus>
111
+ readVoiceover(document: string, signal?: AbortSignal): Promise<VoiceoverSnapshot>
112
+ readVoiceoverJobs(document: string, signal?: AbortSignal): Promise<VoiceoverJobsAnswer>
113
+ readProductionPlan(document: string, query: ProductionPlanQuery, signal?: AbortSignal): Promise<ProductionPlan>
114
+ readProductionAsset(document: string, query: ProductionAssetQuery, signal?: AbortSignal): Promise<ProductionAssetAnswer>
115
+ readProductionMedia(document: string, query: ProductionMediaQuery, signal?: AbortSignal): Promise<Blob>
116
+ /** Allocates an intent before sending; aborted or undelivered requests cancel by intent even without a job id. */
117
+ startVoiceover(document: string, request: Omit<VoiceoverRequest, 'intent'> & { intent?: string }, signal?: AbortSignal): Promise<VoiceoverJob>
118
+ cancelVoiceover(document: string, id: string, signal?: AbortSignal): Promise<VoiceoverJob>
119
+ cancelVoiceoverIntent(document: string, intent: string, signal?: AbortSignal): Promise<VoiceoverCancellation>
120
+ previewVoice(document: string, request: VoicePreviewRequest, signal?: AbortSignal): Promise<VoicePreviewAnswer>
121
+ readVoicePreview(document: string, query: VoicePreviewQuery, signal?: AbortSignal): Promise<VoicePreviewQuote>
122
+ subscribeIr(document: string, revision: (event: IrEvent) => void, failed?: () => void): () => void
123
+ readDiagnostics(document: string): Promise<DiagnosticSnapshot>
124
+ subscribeDiagnostics(document: string, snapshot: (event: DiagnosticSnapshot) => void, failed: () => void): () => void
125
+ reportDiagnostics(report: PreviewDiagnostics): Promise<unknown>
126
+ reportAuthoringDiagnostics(report: AuthoringDiagnostics): Promise<unknown>
127
+ readPreview(document: string, renderer?: PreviewRenderer, signal?: AbortSignal, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner']): Promise<PreviewAnswer>
128
+ /** Asks the service to prepare a form of the document ahead of the author, when the machine has room. */
129
+ prefetchPreview(document: string, renderer: PreviewRenderer): Promise<PreviewAnswer>
130
+ /** The MCP address and the workspace root, read once. */
131
+ readWorkbenchInfo(): Promise<WorkbenchAnswer>
132
+ readFocus(document: string): Promise<FocusAnswer>
133
+ writeFocus(document: string, focus: FocusReport[]): Promise<FocusAnswer>
134
+ subscribeReview(document: string, update: (event: ReviewUpdate) => void): () => void
135
+ acceptReview(document: string, body: { reject?: string[], message?: string }): Promise<{ ended: ReviewEnd }>
136
+ rejectReview(document: string, paths?: string[]): Promise<ReviewUpdate>
137
+ restoreReview(document: string, step: string): Promise<{ review: ReviewSnapshot }>
138
+ readChanges(document: string): Promise<{ files: UncommittedFile[] }>
139
+ commitChanges(document: string, message: string, paths?: string[]): Promise<unknown>
140
+ discardChanges(document: string, paths?: string[]): Promise<unknown>
141
+ readRevisions(document: string, query?: import('@lincyaw/kernel').DocumentHistoryQuery): Promise<RevisionsAnswer>
142
+ readHistoryFile(document: string, query: import('@lincyaw/kernel').DocumentHistoryRead): Promise<import('@lincyaw/kernel').DocumentHistoryFile>
143
+ revertRevision(document: string, request: import('@lincyaw/kernel').DocumentHistoryChange): Promise<import('@lincyaw/kernel').DocumentHistoryChanged>
144
+ /** Takes back the document's latest change; `expect` is the `undo` of the history read, refused when it is not the latest any more. */
145
+ undo(document: string, request: import('@lincyaw/kernel').DocumentHistoryChange): Promise<import('@lincyaw/kernel').DocumentHistoryChanged>
146
+ /** Puts back the change the latest undo took back. */
147
+ redo(document: string, request: import('@lincyaw/kernel').DocumentHistoryChange): Promise<import('@lincyaw/kernel').DocumentHistoryChanged>
148
+ /** What the document can be made into, and its builds and exports. */
149
+ readJobs(document: string): Promise<JobsAnswer>
150
+ /** Starts a build or an export of the document (or answers the one already made from this revision). */
151
+ startJob(document: string, target: string, source: JobSource): Promise<CompileJob>
152
+ cancelJob(document: string, id: string): Promise<CompileJob>
153
+ /** Each change of the document's jobs, as it happens. */
154
+ subscribeJobs(document: string, changed: (job: CompileJob) => void, failed?: () => void, ready?: () => void): () => void
155
+ /** The host's approvals of agent operations; the workbench hears them without being a viewer window. */
156
+ subscribeApprovals(changed: (event: ApprovalEvent) => void, failed?: () => void): () => void
157
+ /** Answers one approval; a 404 means it already ended (answered elsewhere or timed out). */
158
+ answerApproval(id: string, allow: boolean): Promise<void>
159
+ /** Whether the host collects the workbench's operations for its operator. */
160
+ readTelemetry(): Promise<{ enabled: boolean }>
161
+ /** Sends a batch of operations; `beacon` hands it to the browser to deliver while the page goes away. */
162
+ sendTelemetry(batch: OperationBatch, beacon?: boolean): Promise<void>
163
+ }
164
+
165
+ /** A host may carry these event subscriptions over its authenticated shared connection. */
166
+ export interface AuthoringClientChannel {
167
+ publish(connectionId: string, report: import('@lincyaw/kernel').AuthoringClientReport): Promise<void>
168
+ acknowledge(connectionId: string, report: import('@lincyaw/kernel').AuthoringClientAcknowledgement): Promise<void>
169
+ close(): void
170
+ }
171
+ export type AuthoringClientSubscription = (document: string, open: import('@lincyaw/kernel').AuthoringClientOpen, received: (event: import('@lincyaw/kernel').AuthoringClientEvent) => void, failed: () => void) => AuthoringClientChannel
172
+ export interface WorkspaceSubscription {
173
+ (address: string, event: string, received: (data: string, event?: string) => void, failed?: () => void, ready?: () => void): () => void
174
+ openAuthoringClient?: AuthoringClientSubscription
175
+ }
176
+
177
+ /** A remote host may add its CSRF header without replacing the page's global fetch. */
178
+ export interface WorkspaceTransport { fetch?: typeof globalThis.fetch }
179
+
180
+ export function workspaceClient(base = 'api/presmium/', subscription?: WorkspaceSubscription, transport: WorkspaceTransport = {}): WorkspaceClient {
181
+ let client: WorkspaceClient
182
+ const fetch = transport.fetch ?? globalThis.fetch.bind(globalThis)
183
+ const requestListeners = new Set<(event: WorkspaceRequestEvent) => void>()
184
+ let requestSequence = 0
185
+ const observedFetch = async (address: string, init?: RequestInit, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner']): Promise<Response> => {
186
+ const target = new URL(address)
187
+ const prefix = new URL(base, globalThis.document.baseURI).pathname
188
+ const path = target.pathname.slice(prefix.length)
189
+ const method = (init?.method ?? 'GET').toUpperCase()
190
+ if (client.capabilities?.readOnly && !['GET', 'HEAD', 'OPTIONS'].includes(method)
191
+ && /^(?:document(?:\/|$)|source(?:\/|$)|project\/|pages\/|history\/|revisions\/|review(?:\/|$))/.test(path)) throw new WorkspaceServerError(403, { error: 'permission-denied' })
192
+ if (path === 'telemetry' || (path === 'diagnostics' || path === 'diagnostics/authoring') && method === 'POST') return fetch(address, { credentials: 'same-origin', ...init })
193
+ const document = target.searchParams.get('document') ?? undefined
194
+ const requestId = ++requestSequence
195
+ const emit = (kind: WorkspaceRequestEvent['kind'], status?: number) => {
196
+ const event: WorkspaceRequestEvent = { kind, requestId, path, method, ...document !== undefined ? { document } : {}, ...status !== undefined ? { status } : {}, ...feedbackOwner ? { feedbackOwner } : {} }
197
+ for (const listener of requestListeners) {
198
+ const notify = () => {
199
+ if (!requestListeners.has(listener)) return
200
+ // Observers must not alter the request's return value or cancellation.
201
+ try { listener(event) } catch (error) { queueMicrotask(() => { throw error }) }
202
+ }
203
+ // Workspace-scoped source requests need the active document at invocation.
204
+ if (kind === 'start') notify()
205
+ else queueMicrotask(notify)
206
+ }
207
+ }
208
+ emit('start')
209
+ try {
210
+ const response = await fetch(address, { credentials: 'same-origin', ...init })
211
+ if (response.status >= 500 && !isRetryLater(response)) emit('failure', response.status)
212
+ else emit('recovery', response.status)
213
+ return response
214
+ } catch (error) {
215
+ const timedOut = error instanceof Error && error.name === 'TimeoutError' || init?.signal?.reason instanceof Error && init.signal.reason.name === 'TimeoutError'
216
+ const cancelled = !timedOut && (init?.signal?.aborted || error instanceof Error && error.name === 'AbortError')
217
+ if (!cancelled) {
218
+ if (typeof error === 'object' && error !== null) observedFailures.add(error)
219
+ emit('failure')
220
+ } else emit('cancel')
221
+ throw error
222
+ }
223
+ }
224
+ const irCacheLimit = 16 << 20
225
+ type CachedIR = { etag: string, answer: IrAnswer, bytes: number }
226
+ const irCache = new Map<string, CachedIR>()
227
+ const irPending = new Map<string, Promise<IrAnswer>>()
228
+ let irBytes = 0
229
+ let workbenchInfo: Promise<WorkbenchAnswer> | undefined
230
+ const url = (path: string, document?: string, query: Record<string, string> = {}) => {
231
+ const address = new URL(`${base}${path}`, globalThis.document.baseURI)
232
+ if (document !== undefined) address.searchParams.set('document', document)
233
+ for (const [name, value] of Object.entries(query)) address.searchParams.set(name, value)
234
+ return address.href
235
+ }
236
+ const call = async <T>(address: string, init?: RequestInit, feedbackOwner?: WorkspaceRequestEvent['feedbackOwner']): Promise<T> => {
237
+ const response = await observedFetch(address, init, feedbackOwner)
238
+ const data = await response.json().catch(() => ({})) as T & ErrorAnswer
239
+ if (!response.ok) {
240
+ const error = new WorkspaceServerError(response.status, data)
241
+ if (response.status >= 500 && !isRetryLater(response)) observedFailures.add(error)
242
+ throw error
243
+ }
244
+ return data
245
+ }
246
+ const post = <T>(address: string, body: unknown, signal?: AbortSignal) => call<T>(address, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal })
247
+ const startVoiceover: WorkspaceClient['startVoiceover'] = async (document, request, signal) => {
248
+ signal?.throwIfAborted()
249
+ const intent = request.intent ?? Array.from(crypto.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('')
250
+ let cancellation: Promise<VoiceoverCancellation> | undefined
251
+ const cancel = () => cancellation ??= post<VoiceoverCancellation>(url('voiceover/cancel', document), { intent }, AbortSignal.timeout(5000))
252
+ const aborted = () => { void cancel().catch(() => {}) }
253
+ signal?.addEventListener('abort', aborted, { once: true })
254
+ try {
255
+ const job = await post<VoiceoverJob>(url('voiceover', document), { ...request, intent }, signal)
256
+ signal?.throwIfAborted()
257
+ if (job.intent !== intent) throw new Error('voice-intent-changed')
258
+ return job
259
+ } catch (error) {
260
+ // The server may have admitted a job before the response was lost.
261
+ // Cancellation uses a fresh signal; the owned observer reports transport failures.
262
+ if (signal?.aborted || !(error instanceof WorkspaceServerError) || error.status >= 500) await cancel().catch(() => {})
263
+ throw error
264
+ } finally { signal?.removeEventListener('abort', aborted) }
265
+ }
266
+ const rememberIR = (document: string, value?: CachedIR) => {
267
+ const old = irCache.get(document)
268
+ if (old) irBytes -= old.bytes
269
+ irCache.delete(document)
270
+ if (value) {
271
+ irCache.set(document, value)
272
+ irBytes += value.bytes
273
+ }
274
+ while (irCache.size > 2 || irBytes > irCacheLimit) {
275
+ const first = irCache.keys().next().value!
276
+ irBytes -= irCache.get(first)!.bytes
277
+ irCache.delete(first)
278
+ }
279
+ }
280
+ const fetchIR = async (document: string): Promise<IrAnswer> => {
281
+ const known = irCache.get(document)
282
+ const response = await observedFetch(url('ir', document), {
283
+ credentials: 'same-origin',
284
+ cache: 'no-store',
285
+ ...known ? { headers: { 'If-None-Match': known.etag } } : {},
286
+ })
287
+ if (response.status === 304 && known) {
288
+ rememberIR(document, known)
289
+ return known.answer
290
+ }
291
+ const text = await response.text().catch(() => '')
292
+ let data: IrAnswer & ErrorAnswer
293
+ try {
294
+ data = JSON.parse(text) as IrAnswer & ErrorAnswer
295
+ } catch {
296
+ data = {} as IrAnswer & ErrorAnswer
297
+ }
298
+ if (!response.ok) {
299
+ const error = new WorkspaceServerError(response.status, data)
300
+ if (response.status >= 500) observedFailures.add(error)
301
+ throw error
302
+ }
303
+ const etag = response.headers.get('ETag')
304
+ // Account for UTF-16 response text; the parsed object's heap size is a soft limit.
305
+ const bytes = text.length * 2
306
+ rememberIR(document, response.status === 200 && etag && bytes <= irCacheLimit ? { etag, answer: data, bytes } : undefined)
307
+ return data
308
+ }
309
+ const readIr = (document: string): Promise<IrAnswer> => {
310
+ const pending = irPending.get(document)
311
+ if (pending) return pending
312
+ const request = fetchIR(document).finally(() => {
313
+ if (irPending.get(document) === request) irPending.delete(document)
314
+ })
315
+ irPending.set(document, request)
316
+ return request
317
+ }
318
+ const subscribe = <T>(address: string, name: string, on: (data: T) => void, failed?: () => void, ready?: () => void) => {
319
+ const received = (data: string) => {
320
+ try { on(JSON.parse(data) as T) } catch { failed?.() }
321
+ }
322
+ if (subscription) return subscription(address, name, received, failed, ready)
323
+ const source = new EventSource(address)
324
+ source.addEventListener(name, event => received((event as MessageEvent<string>).data))
325
+ if (failed) source.addEventListener('error', failed)
326
+ if (ready) source.addEventListener('open', ready)
327
+ return () => source.close()
328
+ }
329
+ const projectIntents = new Map<string, { id: string, plan?: ProjectPlan }>()
330
+ return client = {
331
+ ...subscription?.openAuthoringClient ? { openAuthoringClient: subscription.openAuthoringClient } : {},
332
+ ...subscription ? { subscribePreviewEvents: ((target, received) => {
333
+ const address = new URL(base, globalThis.document.baseURI)
334
+ for (const [key, value] of Object.entries(target)) if (key !== 'topic') address.searchParams.set(key, value)
335
+ return subscription(address.href, target.topic, (data, event) => received({ type: 'event', event: event ?? '', data }), () => received({ type: 'error' }), () => received({ type: 'open' }))
336
+ }) satisfies PreviewEventSubscription } : {},
337
+ subscribeRequests: listener => { requestListeners.add(listener); return () => { requestListeners.delete(listener) } },
338
+ readDocuments: document => call(url('documents', document)),
339
+ planProjectChange: request => post(url('project/plan'), request),
340
+ async operateDocument(operation, planned) {
341
+ if (operation.op === 'delete' && (!planned || JSON.stringify(planned.operation) !== JSON.stringify(operation))) throw new Error('project-deletion-plan-required')
342
+ const key = JSON.stringify(operation)
343
+ let intent = projectIntents.get(key)
344
+ if (!intent) { intent = { id: crypto.randomUUID() }; projectIntents.set(key, intent) }
345
+ while (projectIntents.size > 64) projectIntents.delete(projectIntents.keys().next().value!)
346
+ try {
347
+ intent.plan ??= planned
348
+ intent.plan ??= await post<ProjectPlan>(url('project/plan'), { id: intent.id, operation })
349
+ const answer = await post<OperationAnswer>(url('project/apply'), { planId: intent.plan.planId, digest: intent.plan.digest, ...operation.op === 'delete' ? { confirmDeletion: true } : {} })
350
+ projectIntents.delete(key)
351
+ return answer
352
+ } catch (error) {
353
+ if (error instanceof WorkspaceServerError && error.status < 500) projectIntents.delete(key)
354
+ throw error
355
+ }
356
+ },
357
+ readSource: (document, path) => call(url('source', document, { path })),
358
+ saveSource: (document, path, content, base, message) => post(url('source', document), { path, content, base, ...message ? { message } : {} }),
359
+ saveSourceTransaction: (document, changes, message) => post(url('source/transaction', document), { changes, ...message ? { message } : {} }),
360
+ readIr,
361
+ readDocument: document => call(url('document', document)),
362
+ readAuthoringCapabilities: (document, signal) => call(url('document/capabilities', document), { signal }),
363
+ planDocumentAction: (document, request, signal) => post(url('document/action/plan', document), request, signal),
364
+ readDocumentActionPlan: (document, reference, signal) => call(url('document/action/plan', document, { planId: reference.planId, digest: reference.digest }), { signal }),
365
+ applyDocumentAction: (document, reference, signal) => post(url('document/action/apply', document), reference, signal),
366
+ documentAssetURL: (document, asset) => url('document/asset', document, { asset }),
367
+ changeDocument: (document, change) => post(url('document/change', document), change),
368
+ importDocumentImage: (document, file, change, signal) => {
369
+ const body = new FormData()
370
+ body.set('change', JSON.stringify(change)); body.set('file', file)
371
+ return call(url('document/image', document), { method: 'POST', body, signal })
372
+ },
373
+ readDocumentAudio: (document, assetId, baseDocumentVersion, signal) => call(url('document/audio', document, { assetId, baseDocumentVersion }), { signal }),
374
+ importDocumentAudio: (document, file, change, signal) => {
375
+ const body = new FormData()
376
+ body.set('change', JSON.stringify(change)); body.set('file', file)
377
+ return call(url('document/audio', document), { method: 'POST', body, signal })
378
+ },
379
+ adoptRecording: (document, request, signal) => post(url('document/recording/adopt', document), request, signal),
380
+ changeDocumentMediaClip: (document, change, signal) => post(url('document/media-clip', document), change, signal),
381
+ readTranscriptionCapabilities: (document, signal) => call(url('document/transcription/capabilities', document), { signal }),
382
+ readTranscriptions: (document, signal) => call(url('document/transcription', document), { signal }),
383
+ startTranscription: (document, request, signal) => post(url('document/transcription', document), request, signal),
384
+ readTranscription: (document, query, signal) => call(url('document/transcription', document, query), { signal }),
385
+ readTranscriptionResult: (document, id, revision, signal) => call(url('document/transcription/result', document, { id, revision }), { signal }),
386
+ cancelTranscription: (document, query, signal) => post(url('document/transcription/cancel', document), query, signal),
387
+ planTranscriptionAdoption: (document, draft, signal) => post(url('document/transcription/adoption/plan', document), draft, signal),
388
+ applyTranscriptionAdoption: (document, request, signal) => post(url('document/transcription/adoption/apply', document), request, signal),
389
+ subscribeDocument: (document, changed, failed) => subscribe(url('document/events', document), 'document', changed, failed),
390
+ readPageDeletion: (document, section) => call(url('pages/delete', document, { section })),
391
+ deletePage: (document, request) => post(url('pages/delete', document), request),
392
+ readSpeechSettings: signal => call(url('speech-settings'), { signal }),
393
+ changeSpeechSettings: (change, signal) => post(url('speech-settings'), change, signal),
394
+ readVoiceCatalog: (document, engine, lang, signal) => call(url('voice-catalog', document, { ...engine ? { engine } : {}, ...lang ? { lang } : {} }), { signal }),
395
+ cloneVoice: (document, request, signal) => {
396
+ const body = new FormData()
397
+ for (const [name, value] of [['engine', request.engine], ['voice', request.voice], ['lang', request.lang], ['speaker', request.speaker], ['scope', request.scope]] as const) if (value) body.set(name, value)
398
+ body.set('confirmed', '1')
399
+ body.set('sample', request.sample, 'sample.wav')
400
+ body.set('statement', request.statement, 'statement.wav')
401
+ return call(url('voice-clone', document), { method: 'POST', body, signal })
402
+ },
403
+ readCloneStatus: (document, voice, engine, signal) => call(url('voice-clone/status', document, { voice, ...engine ? { engine } : {} }), { signal }),
404
+ readVoiceover: (document, signal) => call(url('voiceover', document), { signal }),
405
+ readVoiceoverJobs: (document, signal) => call(url('voiceover/jobs', document), { signal }),
406
+ readProductionPlan: (document, query, signal) => call(url('production-plan', document, { source: JSON.stringify(query.source), irRevision: query.irRevision }), { signal }),
407
+ readProductionAsset: (document, query, signal) => call(url('production-asset', document, { source: JSON.stringify(query.source), irRevision: query.irRevision, assetId: query.assetId }), { signal }),
408
+ async readProductionMedia(document, query, signal) {
409
+ const response = await observedFetch(url('production-media', document, { source: JSON.stringify(query.source), irRevision: query.irRevision, planId: query.planId, clipId: query.clipId }), { signal })
410
+ if (!response.ok) {
411
+ const error = new WorkspaceServerError(response.status, await response.json().catch(() => ({})))
412
+ if (response.status >= 500) observedFailures.add(error)
413
+ throw error
414
+ }
415
+ return response.blob()
416
+ },
417
+ startVoiceover,
418
+ cancelVoiceover: (document, id, signal) => post(url('voiceover/cancel', document), { id }, signal),
419
+ cancelVoiceoverIntent: (document, intent, signal) => post(url('voiceover/cancel', document), { intent }, signal),
420
+ previewVoice: (document, request, signal) => post(url('voice-preview', document), request, signal),
421
+ readVoicePreview: (document, query, signal) => call(url('voice-preview', document, { revision: query.revision, voice: JSON.stringify(query.voice), mode: query.mode, ...query.target ? { target: JSON.stringify(query.target) } : {} }), { signal }),
422
+ subscribeIr: (document, revision, failed) => subscribe(url('ir/events', document), 'revision', revision, failed),
423
+ readDiagnostics: document => call(url('diagnostics', document)),
424
+ subscribeDiagnostics: (document, snapshot, failed) => subscribe(url('diagnostics/events', document), 'diagnostics', snapshot, failed),
425
+ reportDiagnostics: report => post(url('diagnostics'), report),
426
+ reportAuthoringDiagnostics: report => post(url('diagnostics/authoring'), report),
427
+ readPreview: (document, renderer, signal, feedbackOwner) => call(url('preview', document, renderer ? { view: renderer } : {}), { signal }, feedbackOwner),
428
+ prefetchPreview: (document, renderer) => call(url('preview', document, { view: renderer, prefetch: '1' })),
429
+ readWorkbenchInfo: () => {
430
+ workbenchInfo ??= call<WorkbenchAnswer>(url('workbench')).catch((error: unknown) => {
431
+ workbenchInfo = undefined
432
+ throw error
433
+ })
434
+ return workbenchInfo
435
+ },
436
+ readFocus: document => call(url('focus', document)),
437
+ writeFocus: (document, focus) => post(url('focus'), { document, focus }),
438
+ subscribeReview: (document, update) => subscribe(url('review', document), 'review', update),
439
+ acceptReview: (document, body) => post(url('review/accept', document), body),
440
+ rejectReview: (document, paths) => post(url('review/reject', document), paths ? { paths } : {}),
441
+ restoreReview: (document, step) => post(url('review/restore', document), { step }),
442
+ readChanges: document => call(url('changes', document)),
443
+ commitChanges: (document, message, paths) => post(url('changes/commit', document), { message, ...paths ? { paths } : {} }),
444
+ discardChanges: (document, paths) => post(url('changes/discard', document), paths ? { paths } : {}),
445
+ readJobs: document => call(url('jobs', document)),
446
+ startJob: (document, target, source) => post(url('jobs', document), { target, source } satisfies StartJobRequest),
447
+ cancelJob: (document, id) => post(url('jobs/cancel', document), { id }),
448
+ subscribeJobs: (document, changed, failed, ready) => subscribe(url('jobs/events', document), 'job', changed, failed, ready),
449
+ readRevisions: (document, query = {}) => call(url('revisions', document, { query: JSON.stringify(query) })),
450
+ readHistoryFile: (document, query) => call(url('history/file', document, { query: JSON.stringify(query) })),
451
+ revertRevision: (document, request) => post(url('revisions/revert', document), request),
452
+ undo: (document, request) => post(url('history/undo', document), request),
453
+ redo: (document, request) => post(url('history/redo', document), request),
454
+ subscribeApprovals(changed, failed) {
455
+ const address = url('control', '', { observe: 'approvals' })
456
+ const received = (data: string, event?: string) => {
457
+ let value: { id?: unknown, request?: unknown }
458
+ try { value = JSON.parse(data) } catch { return }
459
+ if (typeof value.id !== 'string') return
460
+ if (event === 'approval' && value.request && typeof value.request === 'object') changed({ type: 'approval', id: value.id, request: value.request as ApprovalRequest })
461
+ else if (event === 'settled') changed({ type: 'settled', id: value.id })
462
+ }
463
+ const lost = () => { changed({ type: 'reset' }); failed?.() }
464
+ if (subscription) return subscription(address, 'approvals', received, lost, () => changed({ type: 'reset' }))
465
+ const source = new EventSource(address)
466
+ for (const name of ['approval', 'settled']) source.addEventListener(name, event => received((event as MessageEvent<string>).data, name))
467
+ source.addEventListener('open', () => changed({ type: 'reset' }))
468
+ source.addEventListener('error', lost)
469
+ return () => source.close()
470
+ },
471
+ answerApproval: async (id, allow) => { await post(url('control'), { type: 'answer', id, allow }) },
472
+ readTelemetry: () => call(url('telemetry')),
473
+ sendTelemetry: async (batch, beacon) => {
474
+ const body = JSON.stringify(batch)
475
+ if (beacon && navigator.sendBeacon?.(url('telemetry'), new Blob([body], { type: 'application/json' }))) return
476
+ const response = await fetch(url('telemetry'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body, keepalive: Boolean(beacon) })
477
+ if (!response.ok) throw new Error(`the operations were not taken: HTTP ${response.status}`)
478
+ },
479
+ }
480
+ }
package/src/api.ts DELETED
@@ -1,126 +0,0 @@
1
- import type { Finding, Focus, IRDocument, ReviewChange, ReviewStep, Revision } from '@lincyaw/kernel'
2
-
3
- export interface WorkspaceDocument {
4
- dir: string
5
- title: string
6
- lang?: string
7
- tags?: string[]
8
- }
9
-
10
- export interface DocumentsAnswer {
11
- title: string
12
- documents: WorkspaceDocument[]
13
- templates: string[]
14
- git: boolean
15
- branch?: string
16
- uncommitted?: number
17
- single: boolean
18
- }
19
-
20
- export type DocumentOperation =
21
- | { op: 'create', dir: string, title?: string, template?: string }
22
- | { op: 'rename', dir: string, to: string }
23
- | { op: 'copy', dir: string, to: string }
24
- | { op: 'delete', dir: string }
25
-
26
- export interface OperationAnswer {
27
- revision?: { id: string, summary?: string }
28
- dir?: string
29
- }
30
-
31
- export interface SourceFile {
32
- path: string
33
- binary?: true
34
- }
35
-
36
- export interface FilesAnswer {
37
- document: string
38
- home: boolean
39
- documents?: string[]
40
- files: SourceFile[]
41
- truncated?: true
42
- }
43
-
44
- export interface SourceAnswer {
45
- path: string
46
- content: string
47
- version: string
48
- }
49
-
50
- export interface SavedAnswer {
51
- version: string
52
- }
53
-
54
- export interface IrAnswer {
55
- document: string
56
- revision: string
57
- doc: IRDocument
58
- diagnostics: Finding[]
59
- }
60
-
61
- export interface IrEvent {
62
- document: string
63
- revision: string
64
- diagnostics: { errors: number, warnings: number }
65
- files: string[]
66
- }
67
-
68
- export interface PreviewAnswer {
69
- document: string
70
- web: string
71
- slides?: string
72
- }
73
-
74
- export interface ErrorAnswer {
75
- error?: string
76
- detail?: string
77
- title?: string
78
- conflict?: { version: string, content: string }
79
- locked?: { document: string, holder: string }
80
- }
81
-
82
- export interface WorkbenchAnswer {
83
- mcp: string
84
- root?: string
85
- }
86
-
87
- export interface FocusAnswer {
88
- document?: string
89
- focus: Focus[] | null
90
- reported?: string
91
- }
92
-
93
- export interface ReviewView {
94
- id: string
95
- session: string
96
- turns: { from: number, to: number }
97
- running: boolean
98
- mode: 'required' | 'auto'
99
- steps: ReviewStep[]
100
- changes: ReviewChange[]
101
- }
102
-
103
- export interface ReviewEnd {
104
- outcome: 'accepted' | 'rejected' | 'rolled-back' | 'empty'
105
- revision?: Revision
106
- files: string[]
107
- }
108
-
109
- export interface ReviewFollowed {
110
- document: string
111
- review?: ReviewView
112
- ended?: ReviewEnd
113
- }
114
-
115
- export interface UncommittedFile {
116
- path: string
117
- kind: 'added' | 'modified' | 'deleted'
118
- added?: number
119
- removed?: number
120
- }
121
-
122
- export interface HistoryRevision extends Revision {
123
- reverts?: string
124
- }
125
-
126
- export type RevertAnswer = { ok: true, revision: Revision } | { ok: false, conflicts: string[] }