@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
package/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # @lincyaw/workbench
2
2
 
3
- Presmium's workbench panels in React: the launcher of a workspace (its documents, made from a template, renamed, copied and deleted), a document's file tree, the source editor (CodeMirror 6, with a three-way merge when a file changed on disk), the problems of its compilation, its live preview (the web form or the slides, driven over Presmium's preview protocol) and its changes (an agent's change waiting for review, the changes not committed, the history and its undo). Under them, bound to no framework: the client of the workspace service (`presmium serve`), the workbench's state, the preview protocol's workbench side, and the shared components of Presmium's design (`PmButton`, `PmDialog`, `PmMenu` and the others) in React. Experimental (0.x).
3
+ Presmium's workbench panels in React, for teachers who know no code. The panels are:
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ - the documents of a workspace, listed by title (made from a template, renamed, copied and deleted by title);
6
+ - a document shown as its readers see it (the web form or the slides, driven over Presmium's preview protocol), edited right on the page;
7
+ - the document's comments and its version history.
8
+
9
+ Under them, bound to no framework, are the client of the workspace service (`presmium serve`), the workbench's state, the mapping of an edit on the page back to the source, the preview protocol's workbench side, and the shared components of Presmium's design (`PmButton`, `PmDialog`, `PmMenu` and the others) in React. Experimental (0.x).
10
+
11
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
12
 
7
13
  ## Usage
8
14
 
9
- `presmium studio` shows these panels in DeepSeek Harness's web client: `@lincyaw/dsh` bundles them into its client part and places them. They know nothing of the host they are drawn in; a layout gives them one `Workbench` and places them:
15
+ `presmium studio` shows these panels in DeepSeek Harness's web client: `@lincyaw/dsh` bundles them into its client part and places them. They know nothing of the host they are drawn in. A layout gives them one `Workbench` and places them:
10
16
 
11
17
  ```tsx
12
- import { HomePanel, DocumentView, Workbench, WorkbenchProvider, workspaceClient } from '@lincyaw/workbench'
18
+ import { DocumentView, HomePanel, Workbench, WorkbenchProvider, workspaceClient } from '@lincyaw/workbench'
13
19
  import '@lincyaw/design/design.css'
14
20
  import '@lincyaw/workbench/styles.css'
15
21
 
@@ -20,15 +26,41 @@ export function Centre({ open }: { open: boolean }) {
20
26
  }
21
27
  ```
22
28
 
23
- The workspace service's routes are reached under the page's `api/presmium/` (the host passes them on to the service); React 18 is a peer dependency. Interactive controls wrap Radix Primitives; cmdk owns command search, React Arborist owns the file tree, and react-resizable-panels owns pane resizing. Presmium maintains the visual tokens and the document, save, review and preview behavior, rather than duplicating these libraries' focus, keyboard, positioning and drag logic.
29
+ The page reaches the workspace service's routes under its own `api/presmium/` path; the host passes them on to the service. React 18 is a peer dependency. Dialogs and menus wrap Radix Primitives. Presmium maintains the visual tokens, the editing, the versions and the preview.
30
+
31
+ A host exposing a subset of the workspace service can set `client.capabilities` before constructing `Workbench`: `{ diagnostics: 'none', production: false, narration: false, export: false, readOnly: true }`. This disables diagnostic subscriptions and reports, hides the corresponding document controls, and makes the source session and canonical write requests read-only. Omitted fields preserve the local workspace's complete behavior. Hosts explain unavailable services in their own interface and enforce authorization on every server request. The optional third `workspaceClient` argument, `{ fetch }`, provides an instance-specific transport for authentication headers.
32
+
33
+ `client.capabilities.readOnly: true` also disables canonical document writes, including source transactions, asset imports, comments, page operations and history changes. The source actor and editor controls share this restriction; the service still checks the authenticated member on every request.
34
+
35
+ `DocumentView` keeps the document title, toolbar, editor and preview in one shared view. Its optional `onBack()` callback replaces the default return to the workspace's document list. `actions` places host controls, such as sharing, beside the document's own commands; hosts provide localized accessible names and use the public workbench controls.
36
+
37
+ `historyRevisionAction` adds an explicit host operation to the existing Version History rows. It uses the exported `HistoryRevisionAction` configuration: `label`, optional `icon`, `onSelect(revision)`, optional `disabled` (a boolean or a predicate of the revision), and optional `pendingRevision` (the opaque revision ID being processed). The standalone `HistoryPanel` accepts the same configuration as `revisionAction`. The host owns confirmation, source flushing, authorization, conflict checks and request recovery; it controls pending state while the shared panel disables its other history operations and pagination. Read-only clients cannot activate the action.
38
+
39
+ An absolute snapshot restore is separate from selective undo. A host that stores full snapshots can return `revertible: false` in its history rows and supply a clearly named restore action. Treat revision IDs as opaque references and resolve them on the server; do not reinterpret them in the browser or fetch another history list. Reuse the ordinary compilation and presentation flow, which checks the exact saved source, fingerprint and revision before showing a presentation.
40
+
41
+ ## On the page
42
+
43
+ The document toolbar holds the navigation pane, Undo and Redo, whether the document is saved, Video production, Captions, Comments, Version History, Presentation preview and Export. Below 768 px, Video production, Captions and Version History move into a More menu. When the view owns its title (`framed` is false), Back and the title use their own row; Undo, Redo and Comments join the same More menu so the main commands fit within the available width. A framed view retains its existing single command row.
44
+
45
+ The names follow Microsoft Word's, in Chinese and English.
46
+
47
+ The document is one continuous editor: the author types in place and the change is saved after a short pause. Selecting text opens the formatting toolbar, which includes Add comment. Generated content (components and diagrams) shows its preview, not its source; authors change it through its properties or the AI assistant.
48
+
49
+ Every save is a version. Undo and Redo (Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z) walk the versions, and Version History undoes any one of them.
50
+
51
+ The AI's turns become versions too. When the workspace asks to review them (`review: required`), the author accepts or rejects them first.
52
+
53
+ Comments are saved in the document with the text they point at. The Comments panel lists them; each can be replied to, reacted to, resolved and reopened.
54
+
55
+ Presentation preview shows the slides with the arrow keys and `Esc` working at once; `Esc` first closes an open menu or dialog, then leaves the presentation.
24
56
 
25
- Documents open in preview, with source and side by side modes. Text selections and picked preview blocks become conversation context. `WorkbenchProvider` protects unsaved changes when closing files, leaving a document or unloading the page. Set `protectChanges={false}` on auxiliary providers when several panels share one workbench, keeping the protection mounted once in its primary provider.
57
+ Operations that an outside AI tool asks to perform appear as Approvals in the app bar, where the author allows or denies them.
26
58
 
27
- Hosts route text file references through `workbench.openSource(document, path, { line })`. The file tree and source reads and saves use the opened workspace's scope (`''`), including code outside the current lecture directory. Opening shared code or root notes keeps the selected lecture preview and existing drafts; switching lectures still protects unsaved changes. Without a selected lecture, root files open in source mode. Keep `DocumentView` mounted when hiding it so editor drafts and undo history survive navigation.
59
+ The workbench hands requests to the conversation through `workbench.onAgentRequest`; the host binds that to its agent session. Hosts route links to a document's source through `workbench.revealSource(path, line)`, which opens that document and outlines the block at the line.
28
60
 
29
61
  ## Documentation
30
62
 
31
- - [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/design-dsh.md) (section 7)
63
+ - [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/studio.md) (section 7)
32
64
 
33
65
  ## License
34
66
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lincyaw/workbench",
3
- "version": "0.1.0",
4
- "description": "Presmium's workbench panels in React (home, file tree, source editor, problems, preview, changes) with the workspace service's client and the preview protocol's workbench side; @lincyaw/dsh places them in presmium studio (experimental)",
3
+ "version": "0.2.0-rc.1",
4
+ "description": "Presmium's workbench panels in React (the documents, a document edited on its rendered page with comments and version history) with the workspace service's client and the preview protocol's workbench side; @lincyaw/dsh places them in presmium studio (experimental)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -24,34 +24,38 @@
24
24
  },
25
25
  "exports": {
26
26
  ".": "./src/index.ts",
27
+ "./components": "./src/components.tsx",
27
28
  "./styles.css": "./src/styles.css"
28
29
  },
29
30
  "files": [
30
31
  "src"
31
32
  ],
32
33
  "dependencies": {
33
- "@codemirror/commands": "6.11.1",
34
- "@codemirror/lang-markdown": "6.5.2",
35
- "@codemirror/lang-yaml": "6.1.3",
36
- "@codemirror/language": "6.13.1",
37
- "@codemirror/lint": "6.9.7",
38
- "@codemirror/search": "6.7.2",
39
- "@codemirror/state": "6.7.6",
40
- "@codemirror/view": "6.43.14",
41
- "@lezer/highlight": "1.2.5",
34
+ "@blocknote/core": "0.56.0",
35
+ "@blocknote/mantine": "0.56.0",
36
+ "@blocknote/react": "0.56.0",
37
+ "@dnd-kit/dom": "0.5.1",
38
+ "@dnd-kit/react": "0.5.1",
39
+ "@floating-ui/react": "0.27.20",
40
+ "@mantine/core": "8.3.11",
41
+ "@mantine/hooks": "8.3.11",
42
42
  "@radix-ui/react-dialog": "^1.3.1",
43
43
  "@radix-ui/react-dropdown-menu": "^2.2.1",
44
- "@radix-ui/react-popover": "^1.3.1",
44
+ "@radix-ui/react-popover": "^1.1.15",
45
+ "@radix-ui/react-radio-group": "^1.5.1",
45
46
  "@radix-ui/react-tabs": "^1.2.1",
46
- "@radix-ui/react-toggle-group": "^1.2.1",
47
- "cmdk": "^1.1.1",
48
- "node-diff3": "3.2.1",
49
- "react-arborist": "^4.0.1",
50
- "react-dnd": "^16.0.1",
51
- "react-dnd-html5-backend": "^16.0.1",
52
- "react-resizable-panels": "^4.14.5",
53
- "@lincyaw/design": "1.0.0-rc.0",
54
- "@lincyaw/kernel": "1.0.0-rc.0"
47
+ "@radix-ui/react-toolbar": "^1.2.1",
48
+ "@radix-ui/react-tooltip": "^1.4.1",
49
+ "@rjsf/core": "6.11.0",
50
+ "@rjsf/utils": "6.11.0",
51
+ "@rjsf/validator-ajv8": "6.11.0",
52
+ "@tiptap/core": "3.32.0",
53
+ "@tiptap/extension-file-handler": "3.32.0",
54
+ "@tiptap/pm": "3.32.0",
55
+ "katex": "0.18.10",
56
+ "react-resizable-panels": "4.14.5",
57
+ "@lincyaw/design": "1.0.0-rc.2",
58
+ "@lincyaw/kernel": "1.0.0-rc.2"
55
59
  },
56
60
  "peerDependencies": {
57
61
  "react": "^18.3.1",
@@ -61,6 +65,7 @@
61
65
  "@types/react": "18.3.28",
62
66
  "@types/react-dom": "18.3.7",
63
67
  "react": "^18.3.1",
64
- "react-dom": "^18.3.1"
68
+ "react-dom": "^18.3.1",
69
+ "@lincyaw/compiler": "1.0.0-rc.2"
65
70
  }
66
71
  }
@@ -0,0 +1,122 @@
1
+ import { useId, useRef, useState } from 'react'
2
+ import type { MediaClip, ProductionAssetInspection } from '@lincyaw/kernel'
3
+ import { PmButton, PmField, PmInput, PmNotice, PmRange, PmSelect } from './components.tsx'
4
+ import type { ControlDensity } from './components.tsx'
5
+ import type { Locale } from './messages.ts'
6
+ import { MEDIA_MESSAGES } from './media-messages.ts'
7
+ import { mediaTime, mediaTimeValue as timeValue } from './media-time.ts'
8
+ export { mediaTime } from './media-time.ts'
9
+
10
+ /** Unaccepted input. Untouched fields keep their original numeric precision. */
11
+ export interface MediaClipDraft {
12
+ version: string
13
+ clip: MediaClip
14
+ baseClip?: MediaClip
15
+ startText?: string
16
+ endText?: string
17
+ offsetText?: string
18
+ }
19
+ export interface MediaClipAssetChoice { id: string, label: string }
20
+ export interface MediaClipEditorProps {
21
+ locale: Locale
22
+ density?: ControlDensity
23
+ assets: readonly MediaClipAssetChoice[]
24
+ clips: readonly MediaClip[]
25
+ selectedClipId?: string
26
+ draft: MediaClipDraft
27
+ inspection?: ProductionAssetInspection
28
+ stepDurationMs?: number
29
+ disabled?: boolean
30
+ canApply?: boolean
31
+ loading?: boolean
32
+ busy?: 'import' | 'apply' | 'remove'
33
+ pending?: boolean
34
+ review?: boolean
35
+ storageFailed?: boolean
36
+ failure?: string
37
+ status?: string
38
+ retryImport?: boolean
39
+ onSelectClip(id: string | undefined): void
40
+ onSelectAsset(id: string): void
41
+ onChange(draft: MediaClipDraft): void
42
+ onImport(file: File): void
43
+ onRetryImport(): void
44
+ onRead(): void
45
+ onReview(): void
46
+ onApply(clip: MediaClip): void
47
+ onRemove(): void
48
+ onEditStart(): void
49
+ }
50
+
51
+ function offsetValue(raw: string | undefined, original: number): number | undefined {
52
+ if (raw === undefined || raw === (original / 1000).toFixed(3)) return original
53
+ const text = raw.normalize('NFKC').trim(), value = Number(text) * 1000
54
+ return /^\d+(?:\.\d+)?$/.test(text) && Number.isFinite(value) ? value : undefined
55
+ }
56
+
57
+ /** Compose native public fields; only the caller can adopt a clip into the source. */
58
+ export function MediaClipEditor(props: MediaClipEditorProps) {
59
+ const { locale, density = 'compact', assets, clips, selectedClipId, draft, inspection, stepDurationMs, disabled, busy, loading, review } = props
60
+ const t = MEDIA_MESSAGES[locale], file = useRef<HTMLInputElement>(null), composing = useRef(new Set<string>()), [volumePreview, setVolumePreview] = useState<number>()
61
+ const formatId = useId(), rangeErrorId = useId(), clip = draft.clip
62
+ const startMs = timeValue(draft.startText, clip.sourceRange.startMs), endMs = timeValue(draft.endText, clip.sourceRange.endMs), offsetMs = offsetValue(draft.offsetText, clip.attachment.offsetMs)
63
+ const inspected = inspection?.assetId === clip.assetId ? inspection : undefined
64
+ const rangeInvalid = startMs !== undefined && endMs !== undefined && (startMs < 0 || endMs <= startMs || inspected !== undefined && endMs > inspected.durationMs)
65
+ const exceedsStep = stepDurationMs !== undefined && startMs !== undefined && endMs !== undefined && offsetMs !== undefined && offsetMs + endMs - startMs > stepDurationMs
66
+ const rangeError = rangeInvalid ? t.invalidRange : exceedsStep ? t.exceedsStep : undefined
67
+ const rangeDescription = `${formatId}${rangeError ? ` ${rangeErrorId}` : ''}`
68
+ const originalRemoved = Boolean(selectedClipId && !clips.some(clip => clip.id === selectedClipId))
69
+ const valid = Boolean(inspected && startMs !== undefined && endMs !== undefined && offsetMs !== undefined && !rangeInvalid && !exceedsStep && !originalRemoved)
70
+ const submit = () => {
71
+ if (disabled || busy || !props.canApply || review || !valid || composing.current.size) return
72
+ props.onApply({ ...clip, sourceRange: { startMs: startMs!, endMs: endMs! }, attachment: { ...clip.attachment, offsetMs: offsetMs! } })
73
+ }
74
+ const change = (patch: Partial<MediaClipDraft>) => props.onChange({ ...draft, ...patch })
75
+ const composition = (field: string) => ({ onCompositionStart: () => composing.current.add(field), onCompositionEnd: () => { composing.current.delete(field) } })
76
+ return <section className="pm-ui-media-clip-editor" aria-label={t.title} data-density={density} onFocusCapture={props.onEditStart} onPointerDownCapture={props.onEditStart}
77
+ onKeyDown={event => {
78
+ event.stopPropagation()
79
+ if (event.key === 'Enter' && event.target instanceof HTMLInputElement && event.target.type !== 'range' && !event.nativeEvent.isComposing && !composing.current.size) { event.preventDefault(); submit() }
80
+ }}>
81
+ <div className="pm-ui-media-clip-editor__heading"><h2>{t.title}</h2><PmButton density={density} loading={busy === 'import'} disabled={disabled || Boolean(busy)} onClick={() => file.current?.click()}>{t.import}</PmButton>
82
+ <input ref={file} type="file" hidden accept="audio/*,.wav,.ogg,.mp3,.m4a,.webm" onChange={event => { const selected = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (selected && !disabled && !busy) props.onImport(selected) }} />
83
+ </div>
84
+ <PmSelect label={t.asset} density={density} value={clip.assetId} disabled={disabled || Boolean(busy)} onChange={event => props.onSelectAsset(event.target.value)}>
85
+ <option value="">{t.chooseAsset}</option>{assets.map(asset => <option key={asset.id} value={asset.id}>{asset.label}</option>)}
86
+ {clip.assetId && !assets.some(asset => asset.id === clip.assetId) && <option value={clip.assetId}>{clip.assetId}</option>}
87
+ </PmSelect>
88
+ {clips.length > 0 || selectedClipId ? <PmSelect label={t.clip} density={density} value={selectedClipId ?? ''} disabled={disabled || Boolean(busy)} onChange={event => props.onSelectClip(event.target.value || undefined)}>
89
+ <option value="">{t.newClip}</option>{clips.map((clip, index) => <option key={clip.id} value={clip.id}>{t.clipLabel(index, assets.find(asset => asset.id === clip.assetId)?.label ?? clip.assetId)}</option>)}
90
+ {originalRemoved && <option value={selectedClipId}>{t.removed}</option>}
91
+ </PmSelect> : null}
92
+ {clip.assetId && <>
93
+ {loading ? <span className="pm-ui-field__hint" role="status">{t.loading}</span> : inspected && <output className="pm-ui-field__hint">{t.original(mediaTime(inspected.durationMs), valid ? mediaTime(endMs! - startMs!) : undefined)}</output>}
94
+ <div className="pm-ui-media-clip-editor__times">
95
+ <PmInput label={t.in} density={density} width="time" maxLength={32} value={draft.startText ?? mediaTime(clip.sourceRange.startMs)} aria-describedby={rangeDescription} aria-invalid={rangeInvalid || undefined}
96
+ readOnly={disabled || !inspected} error={startMs === undefined ? t.invalidTime : undefined} onChange={event => change({ startText: event.target.value })} {...composition('in')} />
97
+ <PmInput label={t.out} density={density} width="time" maxLength={32} value={draft.endText ?? mediaTime(clip.sourceRange.endMs)} aria-describedby={rangeDescription} aria-invalid={Boolean(rangeError) || undefined}
98
+ readOnly={disabled || !inspected} error={endMs === undefined ? t.invalidTime : undefined} onChange={event => change({ endText: event.target.value })} {...composition('out')} />
99
+ </div>
100
+ <span className="pm-ui-field__hint" id={formatId}>{t.timeFormat}</span>
101
+ {rangeError && <span id={rangeErrorId} className="pm-ui-field__error" role="alert">{rangeError}</span>}
102
+ <PmInput label={t.offset} density={density} width="number" inputMode="decimal" maxLength={32} value={draft.offsetText ?? (clip.attachment.offsetMs / 1000).toFixed(3)}
103
+ readOnly={disabled || !inspected} error={offsetMs === undefined ? t.invalidOffset : undefined} onChange={event => change({ offsetText: event.target.value })} {...composition('offset')} />
104
+ <PmField label={t.volume} density={density} group><div className="pm-ui-media-clip-editor__volume">
105
+ <PmRange label={t.volume} density={density} value={clip.volume * 100} max={100} disabled={disabled || !inspected} valueText={value => `${Math.round(value)}%`} onPreviewChange={setVolumePreview}
106
+ onValueCommit={value => change({ clip: { ...clip, volume: value / 100 } })} />
107
+ <output>{Math.round(volumePreview ?? clip.volume * 100)}%</output><PmButton density={density} aria-pressed={Boolean(clip.muted)} disabled={disabled || !inspected} onClick={() => change({ clip: { ...clip, muted: !clip.muted } })}>{clip.muted ? t.unmute : t.mute}</PmButton>
108
+ </div></PmField>
109
+ <div className="pm-ui-media-clip-editor__actions">
110
+ <PmButton density={density} variant="primary" loading={busy === 'apply'} disabled={disabled || Boolean(busy) || !props.canApply || review || !valid} onClick={submit}>{selectedClipId ? t.apply : t.add}</PmButton>
111
+ <PmButton density={density} disabled={disabled || !inspected} onClick={() => change({ clip: { ...clip, sourceRange: { startMs: 0, endMs: inspected!.durationMs } }, startText: undefined, endText: undefined })}>{t.restore}</PmButton>
112
+ {selectedClipId && <PmButton density={density} variant="danger" loading={busy === 'remove'} disabled={disabled || Boolean(busy) || originalRemoved} onClick={props.onRemove}>{t.remove}</PmButton>}
113
+ </div>
114
+ </>}
115
+ {originalRemoved ? <PmNotice tone="warn">{t.removed}</PmNotice> : review ? <PmNotice tone="warn"><span>{t.review}</span><PmButton density={density} disabled={disabled || Boolean(busy) || !props.canApply || !inspected} onClick={props.onReview}>{t.check}</PmButton></PmNotice>
116
+ : clip.assetId && !props.canApply && !busy && <span className="pm-ui-field__hint">{t.unavailable}</span>}
117
+ {props.failure && <PmNotice tone="danger"><span>{props.failure}</span><PmButton density={density} disabled={disabled || Boolean(busy)} onClick={props.retryImport ? props.onRetryImport : props.onRead}>{props.retryImport ? t.retryImport : t.retry}</PmButton></PmNotice>}
118
+ {props.storageFailed && <PmNotice tone="warn">{t.storageFailed}</PmNotice>}
119
+ {props.pending && !props.failure && <span className="pm-ui-field__hint" role="status">{t.pending}</span>}
120
+ {props.status && <span className="pm-ui-field__hint" role="status">{props.status}</span>}
121
+ </section>
122
+ }
@@ -0,0 +1,130 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { TeachingDocument } from '@lincyaw/kernel'
3
+ import { PmButton, PmField, PmInput, PmSegmented } from './components.tsx'
4
+ import type { Locale } from './messages.ts'
5
+ import { stepNarrationStarts, stepTimingValue } from './step-production.ts'
6
+ import type { ProductionSource, StepDurationDraft, StepDurationKey, StepTimingPatch } from './step-production.ts'
7
+
8
+ const MESSAGES = {
9
+ 'zh-CN': {
10
+ timing: '播放时机', start: '配音开始', during: '同步动画', after: '动画后', minimum: '最短停留', trailing: '结束停顿', automatic: '自动', unit: '毫秒',
11
+ source: { step: '本步设置', section: '继承本页', document: '继承文稿默认', unset: '未设置,使用讲授默认' },
12
+ restore: '使用继承的时机', restoreHint: '只把本步的开始时机、最短停留与结束停顿改回继承值;为单个讲稿设置的时机保留。',
13
+ legacy: '继承讲稿设置', mixed: '本步讲稿的开始时机不同。',
14
+ unify: '统一本步时机', unifyHint: '只覆盖本步;改回继承后继续使用讲稿原有的时机。',
15
+ invalid: '未采用:请输入不小于 0 的毫秒数。',
16
+ review: '草稿尚未采用。请核对当前来源设置,再修改或使用继承值。', pending: '输入尚未采用。', storageFailure: '无法在本机保留草稿,请保持页面打开。',
17
+ count: (index: number, value: string) => `讲稿 ${index}:${value}`,
18
+ advancedDraft: '有未完成的视频设置,请在视频制作中处理。',
19
+ },
20
+ en: {
21
+ timing: 'Playback timing', start: 'Voiceover starts', during: 'With animation', after: 'After animation', minimum: 'Minimum hold', trailing: 'Trailing pause', automatic: 'Auto', unit: 'ms',
22
+ source: { step: 'This step', section: 'Inherited from page', document: 'Inherited from document', unset: 'Not set; the lecture default applies' },
23
+ restore: 'Use Inherited Timing', restoreHint: 'Returns only this step’s start, minimum hold, and trailing pause to the inherited values. Timing set on individual scripts stays.',
24
+ legacy: 'Inherited from the script', mixed: 'Scripts in this step start at different times.',
25
+ unify: 'Unify Step Timing', unifyHint: 'Applies only to this step. Using inherited timing again returns to each script’s original timing.',
26
+ invalid: 'Not applied: enter a duration of 0 ms or more.',
27
+ review: 'This draft has not been applied. Check the current source setting, then edit it or use the inherited value.', pending: 'Input has not been applied.', storageFailure: 'Couldn’t keep the draft on this device. Keep this page open.',
28
+ count: (index: number, value: string) => `Script ${index}: ${value}`,
29
+ advancedDraft: 'Review the pending settings in Video Production.',
30
+ },
31
+ }
32
+
33
+ interface DurationFieldProps {
34
+ label: string
35
+ value?: number
36
+ source: ProductionSource
37
+ locale: Locale
38
+ readOnly?: boolean
39
+ draft?: StepDurationDraft
40
+ onDraft?(draft: StepDurationDraft | undefined): void
41
+ onChange(value: number | null): void
42
+ onComposition?(composing: boolean): void
43
+ }
44
+
45
+ /** Short author intent, never an estimated timestamp or a second playback clock. */
46
+ function DurationField({ label, value, source, locale, readOnly, draft, onDraft, onChange, onComposition }: DurationFieldProps) {
47
+ const t = MESSAGES[locale], current = useRef({ onComposition, onChange, onDraft, value })
48
+ current.current = { onComposition, onChange, onDraft, value }
49
+ const [text, setText] = useState(draft?.text ?? (value === undefined ? '' : String(value))), [error, setError] = useState(draft?.error ?? false)
50
+ const focused = useRef(false), composing = useRef(false)
51
+ useEffect(() => { if (!focused.current) { setText(draft?.text ?? (value === undefined ? '' : String(value))); setError(draft?.error ?? false) } }, [value, source, draft?.text, draft?.error, draft?.review])
52
+ useEffect(() => () => { if (composing.current) current.current.onComposition?.(false) }, [])
53
+ const commit = (input: string): boolean => {
54
+ const normalized = input.normalize('NFKC').trim()
55
+ if (!normalized) { setError(false); current.current.onDraft?.(undefined); current.current.onChange(null); return true }
56
+ const number = Number(normalized)
57
+ if (!/^\d+(?:\.\d+)?$/.test(normalized) || !Number.isFinite(number)) { setError(true); current.current.onDraft?.({ text: input, error: true }); return false }
58
+ setError(false); current.current.onDraft?.(undefined); current.current.onChange(number); return true
59
+ }
60
+ return <PmInput label={locale === 'zh-CN' ? `${label}(${t.unit})` : `${label} (${t.unit})`} width="number" inputMode="decimal" maxLength={256} value={text} placeholder={t.automatic}
61
+ hint={draft && !error && !draft.review ? `${t.source[source]} · ${t.pending}` : t.source[source]} error={draft?.review ? t.review : error ? t.invalid : undefined} readOnly={readOnly}
62
+ onFocus={() => { focused.current = true }}
63
+ onChange={event => {
64
+ setText(event.target.value)
65
+ if (readOnly) return
66
+ if (composing.current) { setError(false); current.current.onDraft?.({ text: event.target.value, error: false }) }
67
+ else commit(event.target.value)
68
+ }}
69
+ onBlur={() => {
70
+ focused.current = false
71
+ if (readOnly || composing.current || error || draft) return
72
+ setText(current.current.value === undefined ? '' : String(current.current.value))
73
+ }}
74
+ onCompositionStart={() => { composing.current = true; onComposition?.(true) }}
75
+ onCompositionEnd={event => {
76
+ composing.current = false
77
+ if (!readOnly) commit(event.currentTarget.value)
78
+ queueMicrotask(() => current.current.onComposition?.(false))
79
+ }} />
80
+ }
81
+
82
+ export interface StepProductionEditorProps {
83
+ document: TeachingDocument
84
+ stepId: string
85
+ locale: Locale
86
+ readOnly?: boolean
87
+ advanced?: boolean
88
+ onPause(): void
89
+ onInteraction?(): void
90
+ onChange(patch: StepTimingPatch): void
91
+ drafts?: Partial<Record<StepDurationKey, StepDurationDraft>>
92
+ draftStorageFailure?: boolean
93
+ onDraft?(key: StepDurationKey, draft: StepDurationDraft | undefined): void
94
+ onComposition?(key: StepDurationKey, composing: boolean): void
95
+ }
96
+
97
+ /** A reusable view of the canonical step production, including inherited and migrated settings. */
98
+ export function StepProductionEditor({ document, stepId, locale, readOnly, advanced = true, onPause, onInteraction, onChange, drafts, onDraft, draftStorageFailure, onComposition }: StepProductionEditorProps) {
99
+ const t = MESSAGES[locale], [unifying, setUnifying] = useState(false)
100
+ const start = stepTimingValue(document, stepId, 'narrationStart'), minimum = stepTimingValue(document, stepId, 'minimumHoldMs'), trailing = stepTimingValue(document, stepId, 'trailingPauseMs')
101
+ const narrations = stepNarrationStarts(document, stepId), hasOverrides = narrations.some(value => value.overridden)
102
+ const mixed = new Set(narrations.map(value => value.value ?? 'with-animation')).size > 1
103
+ const hasStepTiming = [start, minimum, trailing].some(value => value.source === 'step')
104
+ const apply = (patch: StepTimingPatch) => { if (!readOnly) { onPause(); onChange(patch) } }
105
+ const options = [{ value: 'with-animation', label: t.during, icon: 'timing-during' as const }, { value: 'after-animation', label: t.after, icon: 'timing-after' as const }]
106
+ return <section className="pm-ui-step-production" contentEditable={false} data-step-production={stepId} aria-label={t.timing}
107
+ onFocus={() => { onInteraction?.(); onPause() }} onKeyDown={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}>
108
+ {advanced && <div className="pm-ui-step-production__heading"><span className="pm-ui-field__label">{t.timing}</span>
109
+ <PmButton small variant="ghost" disabled={readOnly || !hasStepTiming && !drafts?.minimumHoldMs && !drafts?.trailingPauseMs} title={t.restoreHint} onClick={() => { onDraft?.('minimumHoldMs', undefined); onDraft?.('trailingPauseMs', undefined); apply({ narrationStart: null, minimumHoldMs: null, trailingPauseMs: null }) }}>{t.restore}</PmButton>
110
+ </div>}
111
+ {!advanced ? <PmField group label={t.start} density="compact" hint={mixed ? t.mixed : undefined}>
112
+ <div className="pm-ui-step-production__actions"><PmSegmented label={t.start} value={mixed ? '' : narrations[0]?.value ?? start.value ?? 'with-animation'} options={options} disabled={readOnly} onChange={value => apply({ narrationStart: value as 'with-animation' | 'after-animation' })} /></div>
113
+ </PmField> : <><PmField group label={t.start} density="compact" hint={hasOverrides ? (mixed ? t.mixed : t.legacy) : t.source[start.source]}>
114
+ {!hasOverrides && <div className="pm-ui-step-production__actions"><PmSegmented label={t.start} value={start.value ?? ''} options={options} disabled={readOnly} onChange={value => apply({ narrationStart: value as 'with-animation' | 'after-animation' })} /></div>}
115
+ {hasOverrides && <>
116
+ <div className="pm-ui-step-production__narration-settings">{narrations.map((entry, index) => <span key={entry.id} className="pm-ui-field__hint">{t.count(index + 1, entry.value === 'after-animation' ? t.after : t.during)}{entry.overridden ? ` · ${t.legacy}` : ` · ${t.source[start.source]}`}</span>)}</div>
117
+ <div className="pm-ui-step-production__actions"><PmButton small disabled={readOnly} onClick={() => setUnifying(value => !value)} aria-expanded={unifying}>{t.unify}</PmButton></div>
118
+ {unifying && <><span className="pm-ui-field__hint">{t.unifyHint}</span><div className="pm-ui-step-production__actions"><PmSegmented label={t.unify} value="" options={options} disabled={readOnly} onChange={value => { apply({ narrationStart: value as 'with-animation' | 'after-animation' }); setUnifying(false) }} /></div></>}
119
+ </>}
120
+ </PmField>
121
+ <div className="pm-ui-step-production__durations">
122
+ <DurationField label={t.minimum} value={minimum.value} source={minimum.source} locale={locale} readOnly={readOnly} draft={drafts?.minimumHoldMs} onDraft={draft => onDraft?.('minimumHoldMs', draft)}
123
+ onChange={value => apply({ minimumHoldMs: value })} onComposition={composing => onComposition?.('minimumHoldMs', composing)} />
124
+ <DurationField label={t.trailing} value={trailing.value} source={trailing.source} locale={locale} readOnly={readOnly} draft={drafts?.trailingPauseMs} onDraft={draft => onDraft?.('trailingPauseMs', draft)}
125
+ onChange={value => apply({ trailingPauseMs: value })} onComposition={composing => onComposition?.('trailingPauseMs', composing)} />
126
+ </div></>}
127
+ {!advanced && (drafts?.minimumHoldMs || drafts?.trailingPauseMs || draftStorageFailure) ? <span className="pm-ui-field__error" role="alert">{t.advancedDraft}</span>
128
+ : draftStorageFailure && <span className="pm-ui-field__error" role="alert">{t.storageFailure}</span>}
129
+ </section>
130
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { ApprovalRequest } from '@lincyaw/kernel'
3
+ import { PmButton, PmNotice, PmPopover } from './components.tsx'
4
+ import { useMessages, useWorkbench } from './context.tsx'
5
+ import { WorkspaceServerError } from './workspace-client.ts'
6
+
7
+ interface Pending { id: string, request: ApprovalRequest }
8
+
9
+ /**
10
+ * The host's operations waiting for the author, such as an external MCP client's import: shown only while one is
11
+ * pending, answered here as in the viewer's "Approvals". The host times them out; a late answer is reported, not retried.
12
+ */
13
+ export function WorkbenchApprovals({ density = 'compact' }: { density?: 'compact' | 'touch' }) {
14
+ const { client } = useWorkbench()
15
+ const t = useMessages()
16
+ const [pending, setPending] = useState<Pending[]>([])
17
+ const [open, setOpen] = useState(false)
18
+ const [answering, setAnswering] = useState<string>()
19
+ const [notice, setNotice] = useState<string>()
20
+ useEffect(() => client.subscribeApprovals((event) => {
21
+ if (event.type === 'reset') setPending([])
22
+ else if (event.type === 'settled') setPending(list => list.filter(item => item.id !== event.id))
23
+ else setPending(list => list.some(item => item.id === event.id) ? list : [...list, { id: event.id, request: event.request }])
24
+ }), [client])
25
+ useEffect(() => { if (!pending.length) setOpen(false) }, [pending.length])
26
+ if (!pending.length) return null
27
+ const answer = (id: string, allow: boolean) => {
28
+ setAnswering(id); setNotice(undefined)
29
+ void client.answerApproval(id, allow).then(
30
+ () => setPending(list => list.filter(item => item.id !== id)),
31
+ (error: unknown) => {
32
+ if (error instanceof WorkspaceServerError && error.status === 404) {
33
+ setPending(list => list.filter(item => item.id !== id))
34
+ setNotice(t.approvalExpired)
35
+ } else setNotice(t.approvalFailed)
36
+ },
37
+ ).finally(() => setAnswering(current => current === id ? undefined : current))
38
+ }
39
+ return (
40
+ <PmPopover open={open} onOpenChange={setOpen} label={t.approvalsTitle} name="pmw-approvals" trigger={
41
+ <PmButton variant="accent" density={density}>{t.approvals(pending.length)}</PmButton>
42
+ }>
43
+ <h2 className="pmw-approvals__heading">{t.approvalsTitle}</h2>
44
+ <ul className="pmw-approvals__list">
45
+ {pending.map(({ id, request }) => (
46
+ <li key={id} className="pmw-approvals__item" data-approval={id}>
47
+ <p className="pmw-approvals__title">{request.title}</p>
48
+ <p className="pmw-muted">{t.approvalFrom(t.approvalOrigin[request.from] ?? t.approvalOtherOrigin)}</p>
49
+ {request.detail && <p className="pmw-approvals__detail">{request.detail}</p>}
50
+ <div className="pmw-approvals__actions">
51
+ <PmButton density={density} disabled={answering === id} onClick={() => answer(id, false)}>{t.approvalDeny}</PmButton>
52
+ <PmButton variant="primary" density={density} loading={answering === id ? true : undefined} disabled={answering === id} onClick={() => answer(id, true)}>{t.approvalAllow}</PmButton>
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ {notice && <PmNotice tone="warn">{notice}</PmNotice>}
58
+ </PmPopover>
59
+ )
60
+ }
@@ -0,0 +1,62 @@
1
+ // What the kitten beside the assistant does, from what the assistant is doing: asking the author to approve
2
+ // something, reading the document it was asked about, writing into it, thinking, just finished, or idle (sitting,
3
+ // then lying down, then asleep the longer nothing happens).
4
+ import { useEffect, useState } from 'react'
5
+ import type { MascotAction } from '@lincyaw/design/mascot'
6
+ import { useWorkbench, useWorkbenchState } from './context.tsx'
7
+
8
+ const DONE_MS = 2400
9
+ const LOAF_MS = 90_000
10
+ const SLEEP_MS = 300_000
11
+
12
+ export interface AssistantActivity {
13
+ /** The assistant's turn is running. */
14
+ running: boolean
15
+ /** It holds a review of the open document: still working in it, and how many changes it has made. */
16
+ review?: { running: boolean, changes: number }
17
+ /** Operations wait for the author's approval. */
18
+ approvals: number
19
+ /** How long ago the assistant last stopped, in milliseconds (Infinity if it has not run). */
20
+ sinceStopped: number
21
+ }
22
+
23
+ export function assistantMascotAction(a: AssistantActivity): MascotAction {
24
+ if (a.approvals > 0) return 'ask'
25
+ if (a.review?.running) return a.review.changes > 0 ? 'write' : 'read'
26
+ if (a.running) return 'think'
27
+ if (a.review && a.review.changes > 0) return 'ask'
28
+ if (a.sinceStopped < DONE_MS) return 'done'
29
+ if (a.sinceStopped > SLEEP_MS) return 'sleep'
30
+ if (a.sinceStopped > LOAF_MS) return 'loaf'
31
+ return 'sit'
32
+ }
33
+
34
+ /** The kitten's action for the assistant, given whether its turn is running; follows the workbench's review and the
35
+ * operations waiting for approval, and moves on by itself as time passes. */
36
+ export function useAssistantMascot(running: boolean): MascotAction {
37
+ const { client } = useWorkbench()
38
+ // two plain values rather than one object, so the selection compares equal while nothing changes
39
+ const reviewRunning = useWorkbenchState(state => state.review?.running)
40
+ const changes = useWorkbenchState(state => state.review?.changes.length)
41
+ const review = reviewRunning === undefined ? undefined : { running: reviewRunning, changes: changes ?? 0 }
42
+ const [approvals, setApprovals] = useState<string[]>([])
43
+ const [stoppedAt, setStoppedAt] = useState<number>(() => performance.now() - LOAF_MS / 2)
44
+ const [, setNow] = useState(0)
45
+ const busy = running || Boolean(review?.running)
46
+ useEffect(() => client.subscribeApprovals(event => {
47
+ if (event.type === 'reset') setApprovals([])
48
+ else if (event.type === 'settled') setApprovals(list => list.filter(id => id !== event.id))
49
+ else setApprovals(list => list.includes(event.id) ? list : [...list, event.id])
50
+ }), [client])
51
+ useEffect(() => { if (!busy) setStoppedAt(performance.now()) }, [busy])
52
+ // Wake up at the next moment the idle kitten changes what it does.
53
+ const since = performance.now() - stoppedAt
54
+ useEffect(() => {
55
+ if (busy) return
56
+ const next = [DONE_MS, LOAF_MS, SLEEP_MS].find(ms => ms > since)
57
+ if (next === undefined) return
58
+ const timer = setTimeout(() => setNow(performance.now()), next - since + 50)
59
+ return () => clearTimeout(timer)
60
+ }, [busy, since])
61
+ return assistantMascotAction({ running, review, approvals: approvals.length, sinceStopped: busy ? 0 : since })
62
+ }
@@ -0,0 +1,44 @@
1
+ import type { Locale } from './messages.ts'
2
+
3
+ const zh = {
4
+ trim: '录音区间', captionReview: '字幕尚未按裁剪后的录音对齐,请核对。',
5
+ title: '原音频', source: '本步原音', choose: '选择已保存音频', removedAudio: '原音频已移除', adopt: '采用为本步配音', adopted: '已采用', retryAdoption: '重试采用', cancelAdoption: '取消采用', adoptionSourceChanged: '文稿已更新,请重试。', adoptionFailed: '无法采用这段音频,请重试。', start: '开始录音', stop: '停止录音', cancel: '取消', cancelTake: '放弃本次录音', rerecord: '重新录音',
6
+ audition: '试听', stopAudition: '停止试听', save: '保存为音频素材', saving: '正在保存原音…', retry: '重试', discard: '放弃候选',
7
+ ready: '原音候选 · 尚未保存', imported: '已保存为音频素材', requesting: '正在等待麦克风许可', recording: '正在录音', stopping: '正在完成录音…',
8
+ cancelSave: '取消保存', currentSource: '文稿尚未保存,请先完成当前输入。', saveFailed: '无法保存原音。录音已保留,请重试。',
9
+ discardTitle: '放弃这份原音?', discardHint: '未保存的录音将被删除,已保存的音频素材不受影响。', keep: '保留原音',
10
+ failures: {
11
+ 'recording-context': '当前环境无法访问麦克风。请使用安全连接,或继续导入音频。',
12
+ 'recording-unsupported': '此浏览器无法录制受支持的音频格式。仍可导入音频。',
13
+ 'recording-permission': '麦克风未获许可。请检查浏览器权限后重试。',
14
+ 'recording-device': '没有可用的麦克风。', 'recording-device-busy': '麦克风当前无法使用,请检查设备后重试。',
15
+ 'recording-device-unsupported': '当前浏览器或设备不支持此录音请求。仍可导入音频。',
16
+ 'recording-device-ended': '麦克风连接已结束,已保留录到的原音。',
17
+ 'recording-failed': '无法完成本次录音。已有原音仍保留。', 'recording-empty': '本次没有录到音频,请重试。',
18
+ 'recording-size': '录音超过 16 MiB。请缩短录音后重试,已有候选仍保留。',
19
+ 'recording-duration': '已达到十分钟录制上限,录音已停止。',
20
+ 'audition-audio': '原音无法播放。请检查音频或重新录制。', 'audition-request': '无法开始试听,请重试。',
21
+ } as Record<string, string>,
22
+ }
23
+ type Messages = typeof zh
24
+ const en: Messages = {
25
+ trim: 'Recording range', captionReview: 'Captions are not aligned to the trimmed recording. Review them.',
26
+ title: 'Original audio', source: 'Original audio for this step', choose: 'Choose Saved Audio', removedAudio: 'Original audio was removed', adopt: 'Use as Step Voiceover', adopted: 'Applied', retryAdoption: 'Retry', cancelAdoption: 'Cancel', adoptionSourceChanged: 'The document changed. Retry.', adoptionFailed: 'Couldn’t use this audio. Try again.', start: 'Start Recording', stop: 'Stop Recording', cancel: 'Cancel', cancelTake: 'Discard This Recording', rerecord: 'Record Again',
27
+ audition: 'Play Sample', stopAudition: 'Stop Sample', save: 'Save as Audio Asset', saving: 'Saving original audio…', retry: 'Retry', discard: 'Discard Candidate',
28
+ ready: 'Original audio candidate · Not saved', imported: 'Saved as an audio asset', requesting: 'Waiting for microphone permission', recording: 'Recording', stopping: 'Finishing recording…',
29
+ cancelSave: 'Cancel', currentSource: 'Finish and save the current document input first.', saveFailed: 'Couldn’t save the original audio. The recording is kept; try again.',
30
+ discardTitle: 'Discard this original audio?', discardHint: 'The recording that is not saved will be deleted. Saved audio assets are kept.', keep: 'Keep Original Audio',
31
+ failures: {
32
+ 'recording-context': 'Microphone access is unavailable here. Use a secure connection or import an audio file.',
33
+ 'recording-unsupported': 'This browser cannot record a supported audio format. Audio file import is still available.',
34
+ 'recording-permission': 'Microphone permission was not granted. Check browser permissions and retry.',
35
+ 'recording-device': 'No microphone is available.', 'recording-device-busy': 'The microphone is unavailable. Check the device and retry.',
36
+ 'recording-device-unsupported': 'This browser or device does not support the recording request. Audio file import is still available.',
37
+ 'recording-device-ended': 'The microphone disconnected. Recorded audio is retained.',
38
+ 'recording-failed': 'Couldn’t finish this recording. Any previous original audio is kept.', 'recording-empty': 'No audio was recorded. Retry.',
39
+ 'recording-size': 'The recording exceeds 16 MiB. Record a shorter take; any previous candidate is retained.',
40
+ 'recording-duration': 'The ten-minute recording limit was reached. Recording stopped.',
41
+ 'audition-audio': 'This audio could not be played. Check it or record again.', 'audition-request': 'Couldn’t start listening. Try again.',
42
+ },
43
+ }
44
+ export const AUDIO_RECORDING_MESSAGES: Record<Locale, Messages> = { 'zh-CN': zh, en }