@linto-ai/transcript-ui-core 0.9.23 → 0.9.24-unstable.202610151359
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.
- package/README.md +32 -0
- package/package.json +3 -3
- package/src/components/Header.vue +54 -43
- package/src/components/Layout.vue +27 -8
- package/src/components/MicrophoneIllustration.vue +5 -5
- package/src/components/SpeakerLabel.vue +31 -16
- package/src/components/SpeakerSidebar.vue +74 -54
- package/src/components/SpeechActivityIndicator.vue +94 -0
- package/src/components/TranscriptUI.vue +33 -3
- package/src/components/TranscriptionPanel.vue +24 -3
- package/src/components/TranscriptionTurn.vue +33 -6
- package/src/composables/useFollowPlayback.ts +4 -1
- package/src/composables/useIsMobile.ts +8 -25
- package/src/core/capabilities.ts +18 -0
- package/src/core/createCore.ts +78 -1
- package/src/core/index.ts +7 -0
- package/src/core/modules/viewport.ts +48 -0
- package/src/core/stores/channelStore.ts +4 -1
- package/src/core/stores/test/translationStore.test.ts +35 -0
- package/src/core/stores/translationStore.ts +5 -2
- package/src/core/test/capabilities.test.ts +32 -0
- package/src/core/test/createCore.test.ts +248 -0
- package/src/core/types.ts +131 -6
- package/src/index.ts +9 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SUMMARY_CAPABILITIES,
|
|
4
|
+
computeCanChat,
|
|
5
|
+
computeSummaryCapabilities,
|
|
6
|
+
} from "../capabilities"
|
|
7
|
+
|
|
8
|
+
const TEXT_ONLY = { text: "edit", speakers: "edit" } as const
|
|
9
|
+
|
|
10
|
+
describe("computeSummaryCapabilities", () => {
|
|
11
|
+
it("falls back on the defaults when the host sets none", () => {
|
|
12
|
+
expect(computeSummaryCapabilities(TEXT_ONLY)).toBe(
|
|
13
|
+
DEFAULT_SUMMARY_CAPABILITIES,
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("returns the host's permissions", () => {
|
|
18
|
+
const summary = { generate: true, edit: false, forceGenerate: true }
|
|
19
|
+
expect(computeSummaryCapabilities({ ...TEXT_ONLY, summary })).toBe(summary)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("computeCanChat", () => {
|
|
24
|
+
it("keeps the chat when the host sets nothing", () => {
|
|
25
|
+
expect(computeCanChat(TEXT_ONLY)).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("follows the host's capability", () => {
|
|
29
|
+
expect(computeCanChat({ ...TEXT_ONLY, chat: "disabled" })).toBe(false)
|
|
30
|
+
expect(computeCanChat({ ...TEXT_ONLY, chat: "enabled" })).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test"
|
|
2
|
+
import { nextTick } from "vue"
|
|
3
|
+
import { createCore } from "../createCore"
|
|
4
|
+
|
|
5
|
+
/** Stands in for the browser's media query so the core can be built at a
|
|
6
|
+
* chosen width and resized — there is no DOM under `bun test`. */
|
|
7
|
+
function installViewport(isMobile: boolean) {
|
|
8
|
+
const listeners = new Set<(event: MediaQueryListEvent) => void>()
|
|
9
|
+
const query = {
|
|
10
|
+
matches: isMobile,
|
|
11
|
+
addEventListener(_: string, listener: (event: MediaQueryListEvent) => void) {
|
|
12
|
+
listeners.add(listener)
|
|
13
|
+
},
|
|
14
|
+
removeEventListener(
|
|
15
|
+
_: string,
|
|
16
|
+
listener: (event: MediaQueryListEvent) => void,
|
|
17
|
+
) {
|
|
18
|
+
listeners.delete(listener)
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(globalThis, "window", {
|
|
22
|
+
value: { matchMedia: () => query },
|
|
23
|
+
configurable: true,
|
|
24
|
+
writable: true,
|
|
25
|
+
})
|
|
26
|
+
return {
|
|
27
|
+
resizeTo(matches: boolean) {
|
|
28
|
+
query.matches = matches
|
|
29
|
+
for (const listener of [...listeners]) {
|
|
30
|
+
listener({ matches } as MediaQueryListEvent)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
listenerCount: () => listeners.size,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function uninstallViewport(): void {
|
|
38
|
+
Reflect.deleteProperty(globalThis, "window")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(uninstallViewport)
|
|
42
|
+
|
|
43
|
+
describe("createCore — sidebar drawer", () => {
|
|
44
|
+
it("opens and closes on mobile, reporting every move to the host", () => {
|
|
45
|
+
installViewport(true)
|
|
46
|
+
const core = createCore()
|
|
47
|
+
const seen: boolean[] = []
|
|
48
|
+
core.on("sidebar:open", ({ open }) => seen.push(open))
|
|
49
|
+
|
|
50
|
+
expect(core.sidebarOpen.value).toBe(false)
|
|
51
|
+
|
|
52
|
+
core.setSidebarOpen(true)
|
|
53
|
+
expect(core.sidebarOpen.value).toBe(true)
|
|
54
|
+
|
|
55
|
+
core.setSidebarOpen(false)
|
|
56
|
+
expect(core.sidebarOpen.value).toBe(false)
|
|
57
|
+
|
|
58
|
+
expect(seen).toEqual([true, false])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("stays silent when the state does not actually change", () => {
|
|
62
|
+
installViewport(true)
|
|
63
|
+
const core = createCore()
|
|
64
|
+
let events = 0
|
|
65
|
+
core.on("sidebar:open", () => events++)
|
|
66
|
+
|
|
67
|
+
core.setSidebarOpen(false)
|
|
68
|
+
core.setSidebarOpen(true)
|
|
69
|
+
core.setSidebarOpen(true)
|
|
70
|
+
|
|
71
|
+
expect(events).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("refuses to open above the breakpoint, and keeps no armed state", () => {
|
|
75
|
+
const viewport = installViewport(false)
|
|
76
|
+
const core = createCore()
|
|
77
|
+
let events = 0
|
|
78
|
+
core.on("sidebar:open", () => events++)
|
|
79
|
+
|
|
80
|
+
core.setSidebarOpen(true)
|
|
81
|
+
expect(core.sidebarOpen.value).toBe(false)
|
|
82
|
+
expect(events).toBe(0)
|
|
83
|
+
|
|
84
|
+
// The drawer appears at phone width — closed, not sprung open by the
|
|
85
|
+
// call made while it did not exist.
|
|
86
|
+
viewport.resizeTo(true)
|
|
87
|
+
expect(core.sidebarOpen.value).toBe(false)
|
|
88
|
+
expect(events).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("closes itself when the viewport leaves phone width", () => {
|
|
92
|
+
const viewport = installViewport(true)
|
|
93
|
+
const core = createCore()
|
|
94
|
+
const seen: boolean[] = []
|
|
95
|
+
core.on("sidebar:open", ({ open }) => seen.push(open))
|
|
96
|
+
|
|
97
|
+
core.setSidebarOpen(true)
|
|
98
|
+
viewport.resizeTo(false)
|
|
99
|
+
|
|
100
|
+
expect(core.sidebarOpen.value).toBe(false)
|
|
101
|
+
expect(seen).toEqual([true, false])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("createCore — viewport", () => {
|
|
106
|
+
it("reports every crossing of the breakpoint to the host", () => {
|
|
107
|
+
const viewport = installViewport(false)
|
|
108
|
+
const core = createCore()
|
|
109
|
+
const seen: boolean[] = []
|
|
110
|
+
core.on("viewport:change", ({ isMobile }) => seen.push(isMobile))
|
|
111
|
+
|
|
112
|
+
expect(core.isMobile.value).toBe(false)
|
|
113
|
+
|
|
114
|
+
viewport.resizeTo(true)
|
|
115
|
+
expect(core.isMobile.value).toBe(true)
|
|
116
|
+
|
|
117
|
+
viewport.resizeTo(false)
|
|
118
|
+
expect(core.isMobile.value).toBe(false)
|
|
119
|
+
|
|
120
|
+
expect(seen).toEqual([true, false])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("releases its listener on destroy", () => {
|
|
124
|
+
const viewport = installViewport(true)
|
|
125
|
+
const core = createCore()
|
|
126
|
+
expect(viewport.listenerCount()).toBe(1)
|
|
127
|
+
|
|
128
|
+
core.destroy()
|
|
129
|
+
expect(viewport.listenerCount()).toBe(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("builds and tears down without a DOM to watch", () => {
|
|
133
|
+
uninstallViewport()
|
|
134
|
+
const core = createCore()
|
|
135
|
+
|
|
136
|
+
expect(core.isMobile.value).toBe(false)
|
|
137
|
+
expect(() => core.destroy()).not.toThrow()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("createCore — transcript font size", () => {
|
|
142
|
+
it("starts at the default and announces every change", async () => {
|
|
143
|
+
const core = createCore()
|
|
144
|
+
const seen: number[] = []
|
|
145
|
+
core.on("transcript:fontSize", ({ fontSize }) => seen.push(fontSize))
|
|
146
|
+
|
|
147
|
+
expect(core.transcriptFontSize.value).toBe(18)
|
|
148
|
+
|
|
149
|
+
core.transcriptFontSize.value = 24
|
|
150
|
+
await nextTick()
|
|
151
|
+
core.transcriptFontSize.value = 14
|
|
152
|
+
await nextTick()
|
|
153
|
+
|
|
154
|
+
expect(seen).toEqual([24, 14])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("stops announcing once destroyed", async () => {
|
|
158
|
+
const core = createCore()
|
|
159
|
+
let events = 0
|
|
160
|
+
core.on("transcript:fontSize", () => events++)
|
|
161
|
+
|
|
162
|
+
core.destroy()
|
|
163
|
+
core.transcriptFontSize.value = 24
|
|
164
|
+
await nextTick()
|
|
165
|
+
|
|
166
|
+
expect(events).toBe(0)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe("createCore — theme", () => {
|
|
171
|
+
it("starts light and announces every switch", async () => {
|
|
172
|
+
const core = createCore()
|
|
173
|
+
const seen: string[] = []
|
|
174
|
+
core.on("theme:change", ({ theme }) => seen.push(theme))
|
|
175
|
+
|
|
176
|
+
expect(core.theme.value).toBe("light")
|
|
177
|
+
|
|
178
|
+
core.theme.value = "dark"
|
|
179
|
+
await nextTick()
|
|
180
|
+
core.theme.value = "light"
|
|
181
|
+
await nextTick()
|
|
182
|
+
|
|
183
|
+
expect(seen).toEqual(["dark", "light"])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("stops announcing once destroyed", async () => {
|
|
187
|
+
const core = createCore()
|
|
188
|
+
let events = 0
|
|
189
|
+
core.on("theme:change", () => events++)
|
|
190
|
+
|
|
191
|
+
core.destroy()
|
|
192
|
+
core.theme.value = "dark"
|
|
193
|
+
await nextTick()
|
|
194
|
+
|
|
195
|
+
expect(events).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe("createCore — active track", () => {
|
|
200
|
+
function track(id: string, isSource: boolean) {
|
|
201
|
+
return { id, languages: ["fr"], isSource, turns: [] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createDocumentCore() {
|
|
205
|
+
installViewport(false)
|
|
206
|
+
const core = createCore()
|
|
207
|
+
core.setDocument({
|
|
208
|
+
title: "",
|
|
209
|
+
speakers: new Map(),
|
|
210
|
+
channels: [
|
|
211
|
+
{
|
|
212
|
+
id: "ch-1",
|
|
213
|
+
name: "",
|
|
214
|
+
duration: 0,
|
|
215
|
+
translations: [track("src-1", true), track("en-1", false)],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "ch-2",
|
|
219
|
+
name: "",
|
|
220
|
+
duration: 0,
|
|
221
|
+
translations: [track("src-2", true), track("en-2", false)],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
})
|
|
225
|
+
const seen: Array<{ channelId: string; translationId: string }> = []
|
|
226
|
+
core.on("activeTrack:change", (payload) => seen.push(payload))
|
|
227
|
+
return { core, seen }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it("announces a track change of the active channel", () => {
|
|
231
|
+
const { core, seen } = createDocumentCore()
|
|
232
|
+
core.channels.get("ch-1")!.setActiveTranslation("en-1")
|
|
233
|
+
expect(seen).toEqual([{ channelId: "ch-1", translationId: "en-1" }])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("announces a channel change with the track that channel was on", () => {
|
|
237
|
+
const { core, seen } = createDocumentCore()
|
|
238
|
+
core.channels.get("ch-2")!.setActiveTranslation("en-2")
|
|
239
|
+
core.setActiveChannel("ch-2")
|
|
240
|
+
expect(seen).toEqual([{ channelId: "ch-2", translationId: "en-2" }])
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("stays silent for a track change of another channel", () => {
|
|
244
|
+
const { core, seen } = createDocumentCore()
|
|
245
|
+
core.channels.get("ch-2")!.setActiveTranslation("en-2")
|
|
246
|
+
expect(seen).toEqual([])
|
|
247
|
+
})
|
|
248
|
+
})
|
package/src/core/types.ts
CHANGED
|
@@ -11,18 +11,44 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
// ── Capabilities ────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
+
/** What the reader may do with the reports (llm-services plugin). Separate
|
|
15
|
+
* permissions rather than levels: a host can offer forcing a generation
|
|
16
|
+
* without editing (e.g. a phone app). */
|
|
17
|
+
export interface SummaryCapabilities {
|
|
18
|
+
/** Generate a report that is missing or out of date. */
|
|
19
|
+
generate: boolean
|
|
20
|
+
/** Edit and save a report by hand. */
|
|
21
|
+
edit: boolean
|
|
22
|
+
/** Generate again a report that is already up to date. */
|
|
23
|
+
forceGenerate: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export interface CoreCapabilities {
|
|
15
27
|
text: "edit" | "view"
|
|
16
28
|
speakers: "edit" | "view"
|
|
29
|
+
/** Left unset: DEFAULT_SUMMARY_CAPABILITIES. */
|
|
30
|
+
summary?: SummaryCapabilities
|
|
31
|
+
/** Whether the reader may chat, once a chat plugin is installed. Left
|
|
32
|
+
* unset: "enabled". */
|
|
33
|
+
chat?: "enabled" | "disabled"
|
|
17
34
|
}
|
|
18
35
|
|
|
19
36
|
// ── Event Map ──────────────────────────────────────────────────────────
|
|
20
37
|
|
|
38
|
+
/** Light is the default; dark redefines the colour tokens on the editor's
|
|
39
|
+
* root, under the same data-theme convention the host app uses. */
|
|
40
|
+
export type EditorTheme = "light" | "dark"
|
|
41
|
+
|
|
21
42
|
export interface CoreEventMap {
|
|
22
43
|
/** A new document was loaded via setDocument (channels rebuilt). */
|
|
23
44
|
"document:change": void
|
|
24
45
|
"channel:change": { channelId: string }
|
|
25
|
-
"translation:change": { translationId: string | null }
|
|
46
|
+
"translation:change": { channelId: string; translationId: string | null }
|
|
47
|
+
/** The displayed track changed: another channel, or another track of the
|
|
48
|
+
* active channel. Follows channel:change / translation:change, so a
|
|
49
|
+
* consumer of "what is on screen" listens to this one alone. Not emitted
|
|
50
|
+
* by setDocument: read core.activeChannel once the document is set. */
|
|
51
|
+
"activeTrack:change": { channelId: string; translationId: string }
|
|
26
52
|
"turn:add": { turn: Turn; translationId: string }
|
|
27
53
|
"turn:update": { turn: Turn; translationId: string }
|
|
28
54
|
"turn:remove": { turnId: string; translationId: string }
|
|
@@ -30,9 +56,9 @@ export interface CoreEventMap {
|
|
|
30
56
|
"speaker:add": { speaker: Speaker }
|
|
31
57
|
"speaker:remove": { speakerId: string }
|
|
32
58
|
"scroll:top": { translationId: string }
|
|
33
|
-
/**
|
|
34
|
-
* click-to-read)
|
|
35
|
-
*
|
|
59
|
+
/** An explicit "take me to this instant" gesture that should resume
|
|
60
|
+
* follow-playback scrolling: core.audio.seekTo (click-to-read) or a
|
|
61
|
+
* click/drag on the player's waveform. */
|
|
36
62
|
"audio:seek": { time: number }
|
|
37
63
|
"translation:sync": { translationId: string }
|
|
38
64
|
"channel:sync": { channelId: string }
|
|
@@ -40,6 +66,25 @@ export interface CoreEventMap {
|
|
|
40
66
|
"watermark:display": { display: boolean }
|
|
41
67
|
"watermark:pin": { pinned: boolean }
|
|
42
68
|
"subtitle:visible": { visible: boolean; height: number }
|
|
69
|
+
/** The reading size of the transcript changed. The editor does not
|
|
70
|
+
* remember it — a host that wants the preference to survive a reload
|
|
71
|
+
* listens here and restores it into core.transcriptFontSize. */
|
|
72
|
+
"transcript:fontSize": { fontSize: number }
|
|
73
|
+
/** The editor switched theme. The editor applies it to itself; a host
|
|
74
|
+
* that wants its own chrome to follow listens here (studio-frontend
|
|
75
|
+
* sets html[data-theme] from it). */
|
|
76
|
+
"theme:change": { theme: EditorTheme }
|
|
77
|
+
/** The sidebar drawer was opened or closed — by the built-in header,
|
|
78
|
+
* by the drawer's own close button, by a channel change, or by the
|
|
79
|
+
* host calling setSidebarOpen. A host rendering its own header (see
|
|
80
|
+
* TranscriptUI's no-header) listens to this to keep its button in
|
|
81
|
+
* sync, since it is not the only thing that moves the drawer. */
|
|
82
|
+
"sidebar:open": { open: boolean }
|
|
83
|
+
/** The viewport crossed the phone-width breakpoint. Same information as
|
|
84
|
+
* core.isMobile, pushed: a host that can't subscribe to a Vue 3 ref (a
|
|
85
|
+
* Vue 2 app, or plain JS around the web component) follows this to show
|
|
86
|
+
* or hide its own sidebar button. */
|
|
87
|
+
"viewport:change": { isMobile: boolean }
|
|
43
88
|
"llmService:regenerate": { id: string }
|
|
44
89
|
"llmService:export": { id: string }
|
|
45
90
|
"llmService:active": { id: string | null }
|
|
@@ -53,6 +98,8 @@ export interface CoreEventMap {
|
|
|
53
98
|
"chat:deleteSession": { sessionId: string }
|
|
54
99
|
"chat:renameSession": { sessionId: string; title: string }
|
|
55
100
|
"chat:send": { content: string }
|
|
101
|
+
/** The action button of an error message (see ChatMessageError) was clicked. */
|
|
102
|
+
"chat:errorAction": { messageId: string; actionId: string }
|
|
56
103
|
destroy: void
|
|
57
104
|
}
|
|
58
105
|
|
|
@@ -81,9 +128,13 @@ export interface ReadableTranslation extends TranslationInfo {
|
|
|
81
128
|
export interface TurnStore {
|
|
82
129
|
readonly id: string
|
|
83
130
|
readonly turns: Readonly<Ref<Turn[]>>
|
|
84
|
-
/** Epoch ms
|
|
131
|
+
/** Epoch ms of the last server-side modification of this track (host- or
|
|
132
|
+
* broadcast-pushed). */
|
|
85
133
|
readonly lastModifiedAt: Ref<number | null>
|
|
86
|
-
|
|
134
|
+
/** Move lastModifiedAt forward only: null, non-finite, or older-or-equal
|
|
135
|
+
* timestamps are ignored (server reads and broadcasts can arrive out of
|
|
136
|
+
* order; a modification never makes the transcription older). */
|
|
137
|
+
advanceLastModifiedAt(ts: number | null): void
|
|
87
138
|
addTurn(turn: Turn): void
|
|
88
139
|
prependTurns(turns: Turn[]): void
|
|
89
140
|
updateTurn(turnId: string, patch: Partial<Turn>): void
|
|
@@ -217,6 +268,8 @@ export interface TurnUpdate {
|
|
|
217
268
|
etime?: number
|
|
218
269
|
/** Per-translation edit version — carried, not consumed yet (resync lot). */
|
|
219
270
|
version?: number
|
|
271
|
+
/** Server last_update of the track after this mutation. */
|
|
272
|
+
lastUpdate?: string
|
|
220
273
|
}
|
|
221
274
|
|
|
222
275
|
/** A full turn as carried by structural broadcasts (turn_split, turns_merged). */
|
|
@@ -237,6 +290,8 @@ export interface TurnSplit {
|
|
|
237
290
|
/** The two halves, in order — the left one keeps the original turn id. */
|
|
238
291
|
turns: WireTurn[]
|
|
239
292
|
version?: number
|
|
293
|
+
/** Server last_update of the track after this mutation. */
|
|
294
|
+
lastUpdate?: string
|
|
240
295
|
}
|
|
241
296
|
|
|
242
297
|
/** Two adjacent turns merged, as broadcast by the server (editor:turns_merged). */
|
|
@@ -247,6 +302,8 @@ export interface TurnsMerged {
|
|
|
247
302
|
removedTurnId: string
|
|
248
303
|
turn: WireTurn
|
|
249
304
|
version?: number
|
|
305
|
+
/** Server last_update of the track after this mutation. */
|
|
306
|
+
lastUpdate?: string
|
|
250
307
|
}
|
|
251
308
|
|
|
252
309
|
/** A turn removed (the client committed an emptied text), as broadcast by
|
|
@@ -256,6 +313,8 @@ export interface TurnDeleted {
|
|
|
256
313
|
turnId: string
|
|
257
314
|
removedSpeakerId?: string
|
|
258
315
|
version?: number
|
|
316
|
+
/** Server last_update of the track after this mutation. */
|
|
317
|
+
lastUpdate?: string
|
|
259
318
|
}
|
|
260
319
|
|
|
261
320
|
/** A turn pointed at a (possibly freshly created) speaker, as broadcast by
|
|
@@ -267,6 +326,8 @@ export interface TurnSpeakerUpdated {
|
|
|
267
326
|
speaker: { id: string; name: string }
|
|
268
327
|
removedSpeakerId?: string
|
|
269
328
|
version?: number
|
|
329
|
+
/** Server last_update of the track after this mutation. */
|
|
330
|
+
lastUpdate?: string
|
|
270
331
|
/** @see SpeakerRenamed.revisionId */
|
|
271
332
|
revisionId?: string | null
|
|
272
333
|
/** @see SpeakerRenamed.redoRevisionId */
|
|
@@ -278,6 +339,8 @@ export interface SpeakerRenamed {
|
|
|
278
339
|
speakerId: string
|
|
279
340
|
name: string
|
|
280
341
|
version?: number
|
|
342
|
+
/** Server last_update of the track after this mutation. */
|
|
343
|
+
lastUpdate?: string
|
|
281
344
|
/** The undo cursor AFTER this broadcast (see onUndo/onRedo server-side) —
|
|
282
345
|
* a plain rename and an undo/redo replaying one are indistinguishable on
|
|
283
346
|
* the wire, and don't need to be: both just mean "the cursor is here
|
|
@@ -299,6 +362,8 @@ export interface SpeakerReplaced {
|
|
|
299
362
|
fromSpeakerId: string
|
|
300
363
|
toSpeakerId: string
|
|
301
364
|
version?: number
|
|
365
|
+
/** Server last_update of the track after this mutation. */
|
|
366
|
+
lastUpdate?: string
|
|
302
367
|
/** @see SpeakerRenamed.revisionId */
|
|
303
368
|
revisionId?: string | null
|
|
304
369
|
/** @see SpeakerRenamed.redoRevisionId */
|
|
@@ -316,6 +381,8 @@ export interface SpeakerRestored {
|
|
|
316
381
|
toSpeakerId: string
|
|
317
382
|
turnIds: string[]
|
|
318
383
|
version?: number
|
|
384
|
+
/** Server last_update of the track after this mutation. */
|
|
385
|
+
lastUpdate?: string
|
|
319
386
|
/** @see SpeakerRenamed.revisionId */
|
|
320
387
|
revisionId?: string | null
|
|
321
388
|
/** @see SpeakerRenamed.redoRevisionId */
|
|
@@ -422,7 +489,11 @@ export interface SubtitlePluginApi {
|
|
|
422
489
|
|
|
423
490
|
// ── LLM Services Plugin API ─────────────────────────────────────────────
|
|
424
491
|
|
|
492
|
+
/** "loading": the host is fetching the state of the report (opening, or the
|
|
493
|
+
* followed track changed). Nothing to show yet, and no action either: the
|
|
494
|
+
* report may well exist. */
|
|
425
495
|
export type LLMServiceStatus =
|
|
496
|
+
| "loading"
|
|
426
497
|
| "idle"
|
|
427
498
|
| "queued"
|
|
428
499
|
| "processing"
|
|
@@ -552,6 +623,20 @@ export interface LiveTranslationEventData {
|
|
|
552
623
|
|
|
553
624
|
export interface LivePluginApi {
|
|
554
625
|
partial: ShallowRef<string | null>
|
|
626
|
+
/** Whether the provisional text of the turn being spoken reaches the
|
|
627
|
+
* screen at all — it feeds both the transcription panel and the live
|
|
628
|
+
* subtitles, so turning it off silences the two of them. */
|
|
629
|
+
partialsVisible: Ref<boolean>
|
|
630
|
+
showPartials(): void
|
|
631
|
+
hidePartials(): void
|
|
632
|
+
/** True while partials keep arriving — the only sign anyone is talking,
|
|
633
|
+
* and the one the UI falls back on once the text itself is hidden. Stays
|
|
634
|
+
* on for silenceDelay after the last partial. */
|
|
635
|
+
isSpeechActive: Ref<boolean>
|
|
636
|
+
/** Milliseconds without a partial before speech counts as over. ASRs
|
|
637
|
+
* differ in how often they emit — one per word, or one every few
|
|
638
|
+
* seconds — so a host tunes this to its backend. */
|
|
639
|
+
silenceDelay: Ref<number>
|
|
555
640
|
hasLiveUpdate: Ref<boolean>
|
|
556
641
|
ttsAvailable: boolean
|
|
557
642
|
ttsEnabled: Ref<boolean>
|
|
@@ -577,6 +662,17 @@ export interface ChatMessage {
|
|
|
577
662
|
tokenCount?: number
|
|
578
663
|
/** True only for the virtual in-flight assistant message during streaming. */
|
|
579
664
|
streaming?: boolean
|
|
665
|
+
/** Set on an assistant message that reports a failure instead of a reply:
|
|
666
|
+
* rendered as an error card, its content is ignored. */
|
|
667
|
+
error?: ChatMessageError
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A failure reported in the thread. The host writes the texts (already
|
|
671
|
+
* translated) and owns the action: clicking it emits "chat:errorAction". */
|
|
672
|
+
export interface ChatMessageError {
|
|
673
|
+
title: string
|
|
674
|
+
description?: string
|
|
675
|
+
action?: { id: string; label: string; icon?: string }
|
|
580
676
|
}
|
|
581
677
|
|
|
582
678
|
export interface ChatSession {
|
|
@@ -639,6 +735,35 @@ export interface Core {
|
|
|
639
735
|
readonly channels: Map<string, ChannelStore>
|
|
640
736
|
readonly activeChannel: ComputedRef<ChannelStore | undefined>
|
|
641
737
|
|
|
738
|
+
/** Reading size of the transcript text, in pixels. Chrome (buttons,
|
|
739
|
+
* headers, the sidebar) keeps its own scale. Writable: there is nothing to
|
|
740
|
+
* guard, and a change is announced by "transcript:fontSize". */
|
|
741
|
+
readonly transcriptFontSize: Ref<number>
|
|
742
|
+
/** Colour theme of the editor. Writable: a host can restore a stored
|
|
743
|
+
* preference, and a change is announced by "theme:change". */
|
|
744
|
+
readonly theme: Ref<EditorTheme>
|
|
745
|
+
/** Brand colour, pushed by the host so the editor's accents match the app
|
|
746
|
+
* around it. Null keeps the editor's own. Applied as an inline style on
|
|
747
|
+
* the editor's root, which is the only thing that outranks the token
|
|
748
|
+
* declarations inside the shadow tree. */
|
|
749
|
+
readonly primaryColor: Ref<string | null>
|
|
750
|
+
|
|
751
|
+
// ── Layout chrome ────────────────────────────────────────────────────
|
|
752
|
+
/** True while the viewport is at phone width — below the breakpoint where
|
|
753
|
+
* Layout swaps the permanent sidebar for the drawer. Read it instead of
|
|
754
|
+
* duplicating the media query: a host rendering its own header needs the
|
|
755
|
+
* exact same threshold to know when its sidebar button is worth showing. */
|
|
756
|
+
readonly isMobile: Readonly<Ref<boolean>>
|
|
757
|
+
/** Whether the sidebar drawer is open. Only the mobile, single-panel
|
|
758
|
+
* layout mounts that drawer — on desktop the sidebar is permanent and in
|
|
759
|
+
* split view there is none. */
|
|
760
|
+
readonly sidebarOpen: Readonly<Ref<boolean>>
|
|
761
|
+
/** Opens or closes that drawer. Opening is refused, silently and without
|
|
762
|
+
* an event, while the viewport is wider than the breakpoint: there is no
|
|
763
|
+
* drawer to open, and a stored "open" would spring it on the next resize.
|
|
764
|
+
* Closing always applies. */
|
|
765
|
+
setSidebarOpen(open: boolean): void
|
|
766
|
+
|
|
642
767
|
// ── Navigation ───────────────────────────────────────────────────────
|
|
643
768
|
setDocument(doc: EditorDocument): void
|
|
644
769
|
setActiveChannel(channelId: string): void
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,9 @@ export {
|
|
|
4
4
|
provideCore,
|
|
5
5
|
useCore,
|
|
6
6
|
CROSS_TRANSLATION_ID,
|
|
7
|
+
DEFAULT_SUMMARY_CAPABILITIES,
|
|
8
|
+
computeSummaryCapabilities,
|
|
9
|
+
computeCanChat,
|
|
7
10
|
} from './core'
|
|
8
11
|
export type {
|
|
9
12
|
Core,
|
|
@@ -11,6 +14,7 @@ export type {
|
|
|
11
14
|
CoreEventMap,
|
|
12
15
|
CoreOptions,
|
|
13
16
|
CoreCapabilities,
|
|
17
|
+
SummaryCapabilities,
|
|
14
18
|
UISlot,
|
|
15
19
|
TurnEventKey,
|
|
16
20
|
ReadableTranslation,
|
|
@@ -47,6 +51,7 @@ export type {
|
|
|
47
51
|
LLMServicesPluginApi,
|
|
48
52
|
ChatRole,
|
|
49
53
|
ChatMessage,
|
|
54
|
+
ChatMessageError,
|
|
50
55
|
ChatSession,
|
|
51
56
|
ChatPluginApi,
|
|
52
57
|
} from './core'
|
|
@@ -67,7 +72,10 @@ export { default as SpeakerPopover } from './components/molecules/SpeakerPopover
|
|
|
67
72
|
export { useEditorReady } from './composables/useEditorReady'
|
|
68
73
|
|
|
69
74
|
// Phone breakpoint shared with plugins, so their panels switch layout at the
|
|
70
|
-
// same width as the core Layout.
|
|
75
|
+
// same width as the core Layout. Reads core.isMobile, so it only works under
|
|
76
|
+
// a provideCore() — inside <TranscriptUI>, in other words. A host rendering
|
|
77
|
+
// its own chrome around the editor reads core.isMobile (or follows the
|
|
78
|
+
// "viewport:change" event) instead of calling this.
|
|
71
79
|
export { useIsMobile } from './composables/useIsMobile'
|
|
72
80
|
|
|
73
81
|
// Adapter API LinTO
|