@antelopejs/dms-ai 0.1.2 → 0.1.4
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 +49 -0
- package/dist/channels/bridge.d.ts +22 -0
- package/dist/channels/bridge.js +87 -0
- package/dist/channels/bridge.js.map +1 -0
- package/dist/channels/channel-service.d.ts +5 -0
- package/dist/channels/channel-service.js +68 -0
- package/dist/channels/channel-service.js.map +1 -0
- package/dist/channels/message-budget.d.ts +3 -0
- package/dist/channels/message-budget.js +29 -0
- package/dist/channels/message-budget.js.map +1 -0
- package/dist/channels/registry.d.ts +7 -0
- package/dist/channels/registry.js +47 -0
- package/dist/channels/registry.js.map +1 -0
- package/dist/channels/sse-stream.d.ts +19 -0
- package/dist/channels/sse-stream.js +59 -0
- package/dist/channels/sse-stream.js.map +1 -0
- package/dist/chatbox/passthrough.d.ts +3 -0
- package/dist/chatbox/passthrough.js +53 -0
- package/dist/chatbox/passthrough.js.map +1 -0
- package/dist/constants/channels.d.ts +30 -0
- package/dist/constants/channels.js +38 -0
- package/dist/constants/channels.js.map +1 -0
- package/dist/constants/chatbox.d.ts +10 -0
- package/dist/constants/chatbox.js +35 -0
- package/dist/constants/chatbox.js.map +1 -0
- package/dist/constants/sidecar.d.ts +4 -0
- package/dist/constants/sidecar.js +3 -1
- package/dist/constants/sidecar.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/sidecar-socket.d.ts +2 -0
- package/dist/lifecycle/sidecar-socket.js +37 -0
- package/dist/lifecycle/sidecar-socket.js.map +1 -0
- package/dist/lifecycle/sidecar-unavailable-error.d.ts +3 -0
- package/dist/lifecycle/sidecar-unavailable-error.js +10 -0
- package/dist/lifecycle/sidecar-unavailable-error.js.map +1 -0
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/routes/channels.d.ts +8 -0
- package/dist/routes/channels.js +52 -0
- package/dist/routes/channels.js.map +1 -0
- package/dist/routes/chatbox.d.ts +7 -0
- package/dist/routes/chatbox.js +47 -0
- package/dist/routes/chatbox.js.map +1 -0
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/sidecar-info.js +0 -2
- package/dist/routes/sidecar-info.js.map +1 -1
- package/frontend-vue/app/components/SettingsView.vue +19 -22
- package/frontend-vue/app/plugins/ai.client.ts +137 -58
- package/frontend-vue/app/runtime/channel-client.ts +265 -0
- package/frontend-vue/app/runtime/chat-transport.ts +82 -0
- package/frontend-vue/app/runtime/constants.ts +31 -15
- package/frontend-vue/app/runtime/current-page.ts +9 -5
- package/frontend-vue/app/runtime/event-stream.ts +75 -0
- package/frontend-vue/app/runtime/overlay.ts +23 -6
- package/frontend-vue/app/runtime/sidecar-status.ts +10 -25
- package/frontend-vue/app/runtime/theme-bridge.ts +16 -4
- package/frontend-vue/package.json +3 -0
- package/frontend-vue/tests/backoff.test.ts +15 -15
- package/frontend-vue/tests/channel-client.test.ts +247 -0
- package/frontend-vue/tests/chat-transport.test.ts +67 -0
- package/frontend-vue/tests/sidecar-status.test.ts +39 -0
- package/package.json +5 -3
- package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +1 -0
- package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +52 -0
- package/sidecar/chatbox/dist/index.html +2 -2
- package/sidecar/dist/agent/disposals.d.ts +5 -0
- package/sidecar/dist/agent/disposals.js +17 -0
- package/sidecar/dist/agent/disposals.js.map +1 -0
- package/sidecar/dist/agent/effective-mode.d.ts +2 -0
- package/sidecar/dist/agent/effective-mode.js +20 -0
- package/sidecar/dist/agent/effective-mode.js.map +1 -0
- package/sidecar/dist/agent/provider.d.ts +2 -2
- package/sidecar/dist/agent/runner-events.d.ts +9 -1
- package/sidecar/dist/agent/runner.d.ts +2 -2
- package/sidecar/dist/agent/runner.js +11 -5
- package/sidecar/dist/agent/runner.js.map +1 -1
- package/sidecar/dist/agent/session.d.ts +3 -3
- package/sidecar/dist/agent/session.js +54 -34
- package/sidecar/dist/agent/session.js.map +1 -1
- package/sidecar/dist/agent/switching-runner.js +16 -14
- package/sidecar/dist/agent/switching-runner.js.map +1 -1
- package/sidecar/dist/agent/turn-end-reasons.d.ts +3 -0
- package/sidecar/dist/agent/turn-end-reasons.js +24 -0
- package/sidecar/dist/agent/turn-end-reasons.js.map +1 -0
- package/sidecar/dist/constants/agent.d.ts +6 -1
- package/sidecar/dist/constants/agent.js +13 -1
- package/sidecar/dist/constants/agent.js.map +1 -1
- package/sidecar/dist/constants/claude.d.ts +27 -1
- package/sidecar/dist/constants/claude.js +49 -0
- package/sidecar/dist/constants/claude.js.map +1 -1
- package/sidecar/dist/constants/codex.d.ts +6 -0
- package/sidecar/dist/constants/codex.js +13 -0
- package/sidecar/dist/constants/codex.js.map +1 -1
- package/sidecar/dist/constants/settings.js.map +1 -1
- package/sidecar/dist/constants/timing.d.ts +1 -0
- package/sidecar/dist/constants/timing.js +1 -0
- package/sidecar/dist/constants/timing.js.map +1 -1
- package/sidecar/dist/protocol/events.d.ts +36 -0
- package/sidecar/dist/protocol/events.js +13 -0
- package/sidecar/dist/protocol/events.js.map +1 -1
- package/sidecar/dist/providers/claude/activity.d.ts +5 -0
- package/sidecar/dist/providers/claude/activity.js +63 -0
- package/sidecar/dist/providers/claude/activity.js.map +1 -0
- package/sidecar/dist/providers/claude/adapter.js +21 -13
- package/sidecar/dist/providers/claude/adapter.js.map +1 -1
- package/sidecar/dist/providers/claude/config.d.ts +2 -1
- package/sidecar/dist/providers/claude/config.js +4 -0
- package/sidecar/dist/providers/claude/config.js.map +1 -1
- package/sidecar/dist/providers/claude/provider.js +18 -25
- package/sidecar/dist/providers/claude/provider.js.map +1 -1
- package/sidecar/dist/providers/claude/result.d.ts +3 -0
- package/sidecar/dist/providers/claude/result.js +61 -0
- package/sidecar/dist/providers/claude/result.js.map +1 -0
- package/sidecar/dist/providers/claude/safe-mode.d.ts +4 -0
- package/sidecar/dist/providers/claude/safe-mode.js +24 -0
- package/sidecar/dist/providers/claude/safe-mode.js.map +1 -0
- package/sidecar/dist/providers/codex/adapter.js +8 -2
- package/sidecar/dist/providers/codex/adapter.js.map +1 -1
- package/sidecar/dist/providers/codex/client.d.ts +1 -0
- package/sidecar/dist/providers/codex/client.js +1 -0
- package/sidecar/dist/providers/codex/client.js.map +1 -1
- package/sidecar/dist/providers/codex/config.js +2 -1
- package/sidecar/dist/providers/codex/config.js.map +1 -1
- package/sidecar/dist/providers/codex/process.js +113 -33
- package/sidecar/dist/providers/codex/process.js.map +1 -1
- package/sidecar/dist/providers/codex/provider.js +6 -4
- package/sidecar/dist/providers/codex/provider.js.map +1 -1
- package/sidecar/dist/server/routing.js +39 -21
- package/sidecar/dist/server/routing.js.map +1 -1
- package/sidecar/dist/server/turn-progress.d.ts +13 -0
- package/sidecar/dist/server/turn-progress.js +72 -0
- package/sidecar/dist/server/turn-progress.js.map +1 -0
- package/sidecar/dist/server/ws.js +4 -2
- package/sidecar/dist/server/ws.js.map +1 -1
- package/sidecar/dist/state/types.d.ts +2 -1
- package/sidecar/dist/state/types.js +1 -0
- package/sidecar/dist/state/types.js.map +1 -1
- package/sidecar/package.json +1 -1
- package/frontend-vue/app/runtime/ws-client.ts +0 -196
- package/frontend-vue/tests/sidecar-auth.test.ts +0 -87
- package/sidecar/chatbox/dist/assets/index-CEZ1NLp9.js +0 -52
- package/sidecar/chatbox/dist/assets/index-D_51Vqqk.css +0 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface StreamEvent {
|
|
2
|
+
name: string
|
|
3
|
+
data: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const DEFAULT_EVENT_NAME = 'message'
|
|
7
|
+
const FRAME_SEPARATOR = /\r?\n\r?\n/
|
|
8
|
+
const LINE_SEPARATOR = /\r?\n/
|
|
9
|
+
const FIELD_SEPARATOR = ':'
|
|
10
|
+
const EVENT_FIELD = 'event'
|
|
11
|
+
const DATA_FIELD = 'data'
|
|
12
|
+
|
|
13
|
+
interface Field {
|
|
14
|
+
name: string
|
|
15
|
+
value: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseField(line: string): Field {
|
|
19
|
+
const boundary = line.indexOf(FIELD_SEPARATOR)
|
|
20
|
+
if (boundary === -1) return { name: line, value: '' }
|
|
21
|
+
const raw = line.slice(boundary + FIELD_SEPARATOR.length)
|
|
22
|
+
return {
|
|
23
|
+
name: line.slice(0, boundary),
|
|
24
|
+
value: raw.startsWith(' ') ? raw.slice(1) : raw,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseFrame(frame: string): StreamEvent | null {
|
|
29
|
+
let name = DEFAULT_EVENT_NAME
|
|
30
|
+
const data: string[] = []
|
|
31
|
+
for (const line of frame.split(LINE_SEPARATOR)) {
|
|
32
|
+
const field = parseField(line)
|
|
33
|
+
if (field.name === EVENT_FIELD) name = field.value
|
|
34
|
+
if (field.name === DATA_FIELD) data.push(field.value)
|
|
35
|
+
}
|
|
36
|
+
if (data.length === 0) return null
|
|
37
|
+
return { name, data: data.join('\n') }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dispatchFrames(
|
|
41
|
+
buffer: string,
|
|
42
|
+
onEvent: (event: StreamEvent) => void,
|
|
43
|
+
): string {
|
|
44
|
+
const frames = buffer.split(FRAME_SEPARATOR)
|
|
45
|
+
const rest = frames.pop() ?? ''
|
|
46
|
+
for (const frame of frames) {
|
|
47
|
+
const event = parseFrame(frame)
|
|
48
|
+
if (event !== null) onEvent(event)
|
|
49
|
+
}
|
|
50
|
+
return rest
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Delivers every event of a text/event-stream body until it ends. Read over
|
|
55
|
+
* fetch rather than EventSource, like the DMS realtime stream, so a failed
|
|
56
|
+
* request surfaces its status and nothing reconnects behind the caller's back.
|
|
57
|
+
*/
|
|
58
|
+
export async function readEventStream(
|
|
59
|
+
body: ReadableStream<Uint8Array>,
|
|
60
|
+
onEvent: (event: StreamEvent) => void,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const reader = body.getReader()
|
|
63
|
+
const decoder = new TextDecoder()
|
|
64
|
+
let buffer = ''
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
const chunk = await reader.read()
|
|
68
|
+
if (chunk.done) return
|
|
69
|
+
buffer += decoder.decode(chunk.value, { stream: true })
|
|
70
|
+
buffer = dispatchFrames(buffer, onEvent)
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
reader.releaseLock()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
OVERLAY_DOM_ID,
|
|
5
5
|
OVERLAY_IFRAME_ID,
|
|
6
6
|
OVERLAY_MIN_WIDTH_PX,
|
|
7
|
+
OVERLAY_OPEN_CHANGE_EVENT,
|
|
7
8
|
OVERLAY_OUTSIDE_TOGGLE_SUPPRESS_MS,
|
|
8
9
|
OVERLAY_PLACEHOLDER_ID,
|
|
9
10
|
OVERLAY_RESIZE_HANDLE_SIZE_PX,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
import {
|
|
22
23
|
type HostTheme,
|
|
23
24
|
installThemeBridge,
|
|
25
|
+
isFromFrame,
|
|
24
26
|
readHostMode,
|
|
25
27
|
} from './theme-bridge'
|
|
26
28
|
|
|
@@ -37,7 +39,9 @@ export interface OverlayHandle {
|
|
|
37
39
|
// use `toggleOpen` so it is never suppressed.
|
|
38
40
|
toggleFromLauncher: () => void
|
|
39
41
|
isOpen: () => boolean
|
|
40
|
-
|
|
42
|
+
/** Called on every open or close, however the panel was toggled. */
|
|
43
|
+
onOpenChange: (listener: (isOpen: boolean) => void) => () => void
|
|
44
|
+
// Point the frame at the chat document. A no-op when the URL is
|
|
41
45
|
// unchanged and already loaded, so a redundant `connected` notify won't reload
|
|
42
46
|
// the chatbox; otherwise it reloads and shows a connecting placeholder until
|
|
43
47
|
// the frame paints.
|
|
@@ -106,9 +110,9 @@ function buildContainer(): HTMLDivElement {
|
|
|
106
110
|
return container
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
// Built with no `src`: the frame is only pointed at the
|
|
110
|
-
//
|
|
111
|
-
//
|
|
113
|
+
// Built with no `src`: the frame is only pointed at the chat via `repoint` once
|
|
114
|
+
// the status controller confirms the sidecar runs, so it never renders the
|
|
115
|
+
// error page of a sidecar that is down.
|
|
112
116
|
function buildIframe(): HTMLIFrameElement {
|
|
113
117
|
const iframe = document.createElement('iframe')
|
|
114
118
|
iframe.id = OVERLAY_IFRAME_ID
|
|
@@ -291,8 +295,8 @@ function createPlaceholderController(
|
|
|
291
295
|
}
|
|
292
296
|
|
|
293
297
|
// Repoint the frame at `url`, deferring the placeholder-hide until it paints so a
|
|
294
|
-
//
|
|
295
|
-
//
|
|
298
|
+
// reload of the chatbox never flashes the previous frame. A no-op reload when
|
|
299
|
+
// the URL is already loaded.
|
|
296
300
|
function createRepointer(
|
|
297
301
|
iframe: HTMLIFrameElement,
|
|
298
302
|
placeholder: PlaceholderController,
|
|
@@ -321,9 +325,14 @@ function setOpen(
|
|
|
321
325
|
stateRef: OverlayPrefsRef,
|
|
322
326
|
isOpen: boolean,
|
|
323
327
|
): void {
|
|
328
|
+
const hasChanged = stateRef.value.isOpen !== isOpen
|
|
324
329
|
stateRef.value = { ...stateRef.value, isOpen }
|
|
325
330
|
applyPrefs(elements, stateRef.value)
|
|
326
331
|
writePrefsDebounced(stateRef.value)
|
|
332
|
+
if (!hasChanged) return
|
|
333
|
+
elements.container.dispatchEvent(
|
|
334
|
+
new CustomEvent(OVERLAY_OPEN_CHANGE_EVENT, { detail: isOpen }),
|
|
335
|
+
)
|
|
327
336
|
}
|
|
328
337
|
|
|
329
338
|
export function injectOverlay(): OverlayHandle | null {
|
|
@@ -360,6 +369,7 @@ export function injectOverlay(): OverlayHandle | null {
|
|
|
360
369
|
// The chatbox header's close button lives inside the iframe; it asks the
|
|
361
370
|
// host to slide the panel away via postMessage.
|
|
362
371
|
globalThis.addEventListener('message', (event: MessageEvent) => {
|
|
372
|
+
if (!isFromFrame(event, elements.iframe)) return
|
|
363
373
|
const type = (event.data as MessageData)?.type
|
|
364
374
|
if (typeof type === 'string' && CLOSE_MESSAGE_TYPES.has(type)) {
|
|
365
375
|
setOpen(elements, stateRef, false)
|
|
@@ -377,6 +387,13 @@ export function injectOverlay(): OverlayHandle | null {
|
|
|
377
387
|
toggle()
|
|
378
388
|
},
|
|
379
389
|
isOpen: (): boolean => stateRef.value.isOpen,
|
|
390
|
+
onOpenChange: (listener) => {
|
|
391
|
+
const onChange = (event: Event): void =>
|
|
392
|
+
listener((event as CustomEvent<boolean>).detail)
|
|
393
|
+
elements.container.addEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
|
|
394
|
+
return () =>
|
|
395
|
+
elements.container.removeEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
|
|
396
|
+
},
|
|
380
397
|
repoint,
|
|
381
398
|
showPlaceholder: (kind) => placeholder.show(kind),
|
|
382
399
|
}
|
|
@@ -14,8 +14,6 @@ export type SidecarStatus =
|
|
|
14
14
|
| typeof SIDECAR_STATUS_UNAVAILABLE
|
|
15
15
|
|
|
16
16
|
interface SidecarInfo {
|
|
17
|
-
port: number | null
|
|
18
|
-
clientToken: string | null
|
|
19
17
|
isRunning: boolean
|
|
20
18
|
hasGivenUp: boolean
|
|
21
19
|
disabled: boolean
|
|
@@ -27,14 +25,14 @@ export interface SidecarStatusController {
|
|
|
27
25
|
// is disabled for this process — so the caller skips injection entirely.
|
|
28
26
|
init: () => Promise<boolean>
|
|
29
27
|
getStatus: () => SidecarStatus
|
|
30
|
-
getPort: () => number | null
|
|
31
|
-
getClientToken: () => string | null
|
|
32
28
|
// Subscribe to status changes; fires immediately with the current status so a
|
|
33
29
|
// late subscriber (overlay/icon injected after init) reflects it right away.
|
|
34
30
|
subscribe: (cb: (status: SidecarStatus) => void) => () => void
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The host channel dropped: re-probe now, which also revives an idle-exited
|
|
33
|
+
* sidecar, and report `connected` again once it answers.
|
|
34
|
+
*/
|
|
35
|
+
reportChannelDown: () => void
|
|
38
36
|
dispose: () => void
|
|
39
37
|
}
|
|
40
38
|
|
|
@@ -46,9 +44,6 @@ async function fetchInfo(
|
|
|
46
44
|
try {
|
|
47
45
|
const body = await fetchDetails()
|
|
48
46
|
return {
|
|
49
|
-
port: typeof body.port === 'number' ? body.port : null,
|
|
50
|
-
clientToken:
|
|
51
|
-
typeof body.clientToken === 'string' ? body.clientToken : null,
|
|
52
47
|
isRunning: body.isRunning === true,
|
|
53
48
|
hasGivenUp: body.hasGivenUp === true,
|
|
54
49
|
disabled: body.disabled === true,
|
|
@@ -64,8 +59,6 @@ export function createSidecarStatusController(
|
|
|
64
59
|
const backoff = createBackoff()
|
|
65
60
|
const subscribers = new Set<(status: SidecarStatus) => void>()
|
|
66
61
|
let status: SidecarStatus = SIDECAR_STATUS_CONNECTING
|
|
67
|
-
let port: number | null = null
|
|
68
|
-
let clientToken: string | null = null
|
|
69
62
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
70
63
|
let disposed = false
|
|
71
64
|
|
|
@@ -105,19 +98,13 @@ export function createSidecarStatusController(
|
|
|
105
98
|
setStatus(SIDECAR_STATUS_UNAVAILABLE)
|
|
106
99
|
return
|
|
107
100
|
}
|
|
108
|
-
if (info.isRunning
|
|
109
|
-
port = info.port
|
|
110
|
-
clientToken = info.clientToken
|
|
101
|
+
if (info.isRunning) {
|
|
111
102
|
resetBackoff(backoff)
|
|
112
|
-
|
|
113
|
-
// while already connected leaves the status word unchanged, so force a
|
|
114
|
-
// notify there too to drive the overlay/WS onto the new port.
|
|
115
|
-
if (status === SIDECAR_STATUS_CONNECTED) notify()
|
|
116
|
-
else setStatus(SIDECAR_STATUS_CONNECTED)
|
|
103
|
+
setStatus(SIDECAR_STATUS_CONNECTED)
|
|
117
104
|
return
|
|
118
105
|
}
|
|
119
106
|
// Down but not terminal — the probe already asked the backend to respawn;
|
|
120
|
-
// keep polling until it reports
|
|
107
|
+
// keep polling until it reports the sidecar running.
|
|
121
108
|
setStatus(SIDECAR_STATUS_REVIVING)
|
|
122
109
|
schedulePoll()
|
|
123
110
|
}
|
|
@@ -131,8 +118,6 @@ export function createSidecarStatusController(
|
|
|
131
118
|
return true
|
|
132
119
|
},
|
|
133
120
|
getStatus: () => status,
|
|
134
|
-
getPort: () => port,
|
|
135
|
-
getClientToken: () => clientToken,
|
|
136
121
|
subscribe: (cb) => {
|
|
137
122
|
subscribers.add(cb)
|
|
138
123
|
cb(status)
|
|
@@ -140,13 +125,13 @@ export function createSidecarStatusController(
|
|
|
140
125
|
subscribers.delete(cb)
|
|
141
126
|
}
|
|
142
127
|
},
|
|
143
|
-
|
|
128
|
+
reportChannelDown: () => {
|
|
144
129
|
if (disposed) return
|
|
145
130
|
if (status !== SIDECAR_STATUS_CONNECTED) return
|
|
146
131
|
// Surface the drop right away (amber dot + spinner) instead of waiting on
|
|
147
132
|
// the re-probe: that probe awaits a respawn and can take a second or two,
|
|
148
133
|
// during which the icon would otherwise look healthy. poll() flips back
|
|
149
|
-
// to connected once the
|
|
134
|
+
// to connected once the sidecar answers again.
|
|
150
135
|
resetBackoff(backoff)
|
|
151
136
|
setStatus(SIDECAR_STATUS_REVIVING)
|
|
152
137
|
void poll()
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Theme bridge (host → chatbox iframe). The host DMS is the source of truth for
|
|
2
2
|
// the active Nuxt UI primary palette and the light/dark mode; the chatbox iframe
|
|
3
|
-
// is a separate document
|
|
4
|
-
//
|
|
3
|
+
// is a separate document, so it cannot inherit them through CSS — we forward
|
|
4
|
+
// them explicitly here. Both documents share the dashboard's origin, which is
|
|
5
|
+
// the only one either side posts to or accepts messages from.
|
|
5
6
|
//
|
|
6
7
|
// Protocol:
|
|
7
8
|
// iframe → host : { type: "dms-ai:ready" } (chatbox announces mount)
|
|
@@ -113,6 +114,17 @@ interface MessageData {
|
|
|
113
114
|
|
|
114
115
|
type ThemePush = (force: boolean) => void
|
|
115
116
|
|
|
117
|
+
/** True for a message the chatbox frame itself posted, from our own origin. */
|
|
118
|
+
export function isFromFrame(
|
|
119
|
+
event: MessageEvent,
|
|
120
|
+
iframe: HTMLIFrameElement,
|
|
121
|
+
): boolean {
|
|
122
|
+
return (
|
|
123
|
+
event.source === iframe.contentWindow &&
|
|
124
|
+
event.origin === globalThis.location.origin
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
116
128
|
// Only answer the handshake from our own iframe, never an arbitrary frame.
|
|
117
129
|
function answerReadyHandshake(
|
|
118
130
|
iframe: HTMLIFrameElement,
|
|
@@ -120,7 +132,7 @@ function answerReadyHandshake(
|
|
|
120
132
|
): void {
|
|
121
133
|
globalThis.addEventListener('message', (event: MessageEvent) => {
|
|
122
134
|
const type = (event.data as MessageData)?.type
|
|
123
|
-
if (type === READY_MESSAGE_TYPE && event
|
|
135
|
+
if (type === READY_MESSAGE_TYPE && isFromFrame(event, iframe)) {
|
|
124
136
|
post(true)
|
|
125
137
|
}
|
|
126
138
|
})
|
|
@@ -165,7 +177,7 @@ export function installThemeBridge(
|
|
|
165
177
|
onTheme?.(theme)
|
|
166
178
|
iframe.contentWindow?.postMessage(
|
|
167
179
|
{ type: THEME_MESSAGE_TYPE, ...theme },
|
|
168
|
-
|
|
180
|
+
globalThis.location.origin,
|
|
169
181
|
)
|
|
170
182
|
}
|
|
171
183
|
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"packageManager": "pnpm@10.6.5",
|
|
8
|
+
"engines": {
|
|
9
|
+
"@antelopejs/dms-frontend": ">=0.2.8 <0.4.0"
|
|
10
|
+
},
|
|
8
11
|
"scripts": {
|
|
9
12
|
"build": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" build",
|
|
10
13
|
"typecheck": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" typecheck",
|
|
@@ -4,41 +4,41 @@ import {
|
|
|
4
4
|
nextDelay,
|
|
5
5
|
resetBackoff,
|
|
6
6
|
} from '../app/runtime/backoff'
|
|
7
|
-
import {
|
|
7
|
+
import { CHANNEL_RECONNECT_DELAYS_MS } from '../app/runtime/constants'
|
|
8
8
|
|
|
9
9
|
describe('backoff helper', () => {
|
|
10
10
|
it('returns each schedule entry in order then caps at the last value', () => {
|
|
11
11
|
const state = createBackoff()
|
|
12
12
|
const observed: number[] = []
|
|
13
|
-
for (let i = 0; i <
|
|
14
|
-
observed.push(nextDelay(state,
|
|
13
|
+
for (let i = 0; i < CHANNEL_RECONNECT_DELAYS_MS.length + 3; i += 1) {
|
|
14
|
+
observed.push(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS))
|
|
15
15
|
}
|
|
16
16
|
const expected = [
|
|
17
|
-
...
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
...CHANNEL_RECONNECT_DELAYS_MS,
|
|
18
|
+
CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
|
|
19
|
+
CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
|
|
20
|
+
CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
|
|
21
21
|
]
|
|
22
22
|
expect(observed).toEqual(expected)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
it('resetBackoff returns the schedule to schedule[0]', () => {
|
|
26
26
|
const state = createBackoff()
|
|
27
|
-
nextDelay(state,
|
|
28
|
-
nextDelay(state,
|
|
27
|
+
nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
|
|
28
|
+
nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
|
|
29
29
|
resetBackoff(state)
|
|
30
|
-
expect(nextDelay(state,
|
|
31
|
-
|
|
30
|
+
expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(
|
|
31
|
+
CHANNEL_RECONNECT_DELAYS_MS[0],
|
|
32
32
|
)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
it('uses the configured 30s ceiling on the last delay', () => {
|
|
36
36
|
const state = createBackoff()
|
|
37
|
-
const lastIndex =
|
|
37
|
+
const lastIndex = CHANNEL_RECONNECT_DELAYS_MS.length - 1
|
|
38
38
|
for (let i = 0; i < lastIndex; i += 1) {
|
|
39
|
-
nextDelay(state,
|
|
39
|
+
nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
|
|
40
40
|
}
|
|
41
|
-
expect(nextDelay(state,
|
|
42
|
-
expect(nextDelay(state,
|
|
41
|
+
expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(30_000)
|
|
42
|
+
expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(30_000)
|
|
43
43
|
})
|
|
44
44
|
})
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
type ChannelStatus,
|
|
4
|
+
createChannelClient,
|
|
5
|
+
} from '../app/runtime/channel-client'
|
|
6
|
+
import { CHANNEL_RECONNECT_DELAYS_MS } from '../app/runtime/constants'
|
|
7
|
+
|
|
8
|
+
interface FakeStream {
|
|
9
|
+
path: string
|
|
10
|
+
emit: (name: string, data: string) => void
|
|
11
|
+
end: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Harness {
|
|
15
|
+
streams: FakeStream[]
|
|
16
|
+
posts: Array<{ path: string; body: unknown }>
|
|
17
|
+
statuses: ChannelStatus[]
|
|
18
|
+
ready: ReturnType<typeof vi.fn>
|
|
19
|
+
frames: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const encoder = new TextEncoder()
|
|
23
|
+
const READY = '{"connections":{"host":"h-1","chat":"c 1"}}'
|
|
24
|
+
|
|
25
|
+
function openFakeStream(
|
|
26
|
+
streams: FakeStream[],
|
|
27
|
+
path: string,
|
|
28
|
+
signal: AbortSignal,
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
31
|
+
const body = new ReadableStream<Uint8Array>({
|
|
32
|
+
start: (c) => {
|
|
33
|
+
controller = c
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
signal.addEventListener('abort', () =>
|
|
37
|
+
controller.error(new DOMException('aborted', 'AbortError')),
|
|
38
|
+
)
|
|
39
|
+
streams.push({
|
|
40
|
+
path,
|
|
41
|
+
emit: (name, data) =>
|
|
42
|
+
controller.enqueue(encoder.encode(`event: ${name}\ndata: ${data}\n\n`)),
|
|
43
|
+
end: () => controller.close(),
|
|
44
|
+
})
|
|
45
|
+
return Promise.resolve(new Response(body, { status: 200 }))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function startClient(post = vi.fn(async () => undefined)) {
|
|
49
|
+
const harness: Harness = {
|
|
50
|
+
streams: [],
|
|
51
|
+
posts: [],
|
|
52
|
+
statuses: [],
|
|
53
|
+
ready: vi.fn(),
|
|
54
|
+
frames: [],
|
|
55
|
+
}
|
|
56
|
+
const client = createChannelClient({
|
|
57
|
+
channels: ['host', 'chat'],
|
|
58
|
+
post: async (path, body) => {
|
|
59
|
+
harness.posts.push({ path, body })
|
|
60
|
+
return post(path, body)
|
|
61
|
+
},
|
|
62
|
+
openStream: (path, signal) => openFakeStream(harness.streams, path, signal),
|
|
63
|
+
onReady: harness.ready,
|
|
64
|
+
onFrame: (channel, raw) => harness.frames.push(`${channel} ${raw}`),
|
|
65
|
+
onStatusChange: (status) => harness.statuses.push(status),
|
|
66
|
+
})
|
|
67
|
+
client.start()
|
|
68
|
+
return { client, harness }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function flush(): Promise<void> {
|
|
72
|
+
for (let i = 0; i < 10; i += 1) await Promise.resolve()
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
vi.useRealTimers()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('channel client', () => {
|
|
81
|
+
it('stays closed until started', async () => {
|
|
82
|
+
const streams: FakeStream[] = []
|
|
83
|
+
const client = createChannelClient({
|
|
84
|
+
channels: ['host', 'chat'],
|
|
85
|
+
post: vi.fn(),
|
|
86
|
+
openStream: (path, signal) => openFakeStream(streams, path, signal),
|
|
87
|
+
})
|
|
88
|
+
await flush()
|
|
89
|
+
expect(streams).toEqual([])
|
|
90
|
+
expect(client.getStatus()).toBe('disconnected')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('opens one stream for every channel, on the dashboard origin, with no credential', async () => {
|
|
94
|
+
const { client, harness } = startClient()
|
|
95
|
+
await flush()
|
|
96
|
+
expect(harness.streams.map((stream) => stream.path)).toEqual([
|
|
97
|
+
'/ai/channels/host,chat/events',
|
|
98
|
+
])
|
|
99
|
+
client.stop()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('announces the connection, then routes each frame by channel, raw', async () => {
|
|
103
|
+
const { client, harness } = startClient()
|
|
104
|
+
await flush()
|
|
105
|
+
harness.streams[0]?.emit('ready', READY)
|
|
106
|
+
harness.streams[0]?.emit('host', '{"type":"host_command_navigate","path":"/x"}')
|
|
107
|
+
harness.streams[0]?.emit('chat', '{"type":"run_done"}')
|
|
108
|
+
harness.streams[0]?.emit('unknown', '{}')
|
|
109
|
+
await flush()
|
|
110
|
+
expect(harness.ready).toHaveBeenCalledTimes(1)
|
|
111
|
+
expect(client.isConnected()).toBe(true)
|
|
112
|
+
expect(harness.frames).toEqual([
|
|
113
|
+
'host {"type":"host_command_navigate","path":"/x"}',
|
|
114
|
+
'chat {"type":"run_done"}',
|
|
115
|
+
])
|
|
116
|
+
client.stop()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('posts one message at a time, in order, to the connection of its channel', async () => {
|
|
120
|
+
const pending: Array<() => void> = []
|
|
121
|
+
const post = vi.fn(
|
|
122
|
+
() => new Promise<void>((resolve) => pending.push(() => resolve())),
|
|
123
|
+
)
|
|
124
|
+
const { client, harness } = startClient(post)
|
|
125
|
+
await flush()
|
|
126
|
+
harness.streams[0]?.emit('ready', READY)
|
|
127
|
+
await flush()
|
|
128
|
+
client.send('chat', { n: 1 })
|
|
129
|
+
client.send('host', { n: 2 })
|
|
130
|
+
await flush()
|
|
131
|
+
expect(harness.posts).toEqual([
|
|
132
|
+
{ path: '/ai/channels/c%201/messages', body: { n: 1 } },
|
|
133
|
+
])
|
|
134
|
+
pending.shift()?.()
|
|
135
|
+
await flush()
|
|
136
|
+
expect(harness.posts.at(-1)).toEqual({
|
|
137
|
+
path: '/ai/channels/h-1/messages',
|
|
138
|
+
body: { n: 2 },
|
|
139
|
+
})
|
|
140
|
+
client.stop()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('drops a message sent before the connection is ready, and says so', async () => {
|
|
144
|
+
const { client, harness } = startClient()
|
|
145
|
+
expect(client.send('chat', { n: 1 })).toBe(false)
|
|
146
|
+
await flush()
|
|
147
|
+
expect(harness.posts).toEqual([])
|
|
148
|
+
harness.streams[0]?.emit('ready', READY)
|
|
149
|
+
await flush()
|
|
150
|
+
expect(client.send('chat', { n: 2 })).toBe(true)
|
|
151
|
+
client.stop()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('refuses a message for a channel the stream does not carry', async () => {
|
|
155
|
+
const { client, harness } = startClient()
|
|
156
|
+
await flush()
|
|
157
|
+
harness.streams[0]?.emit('ready', READY)
|
|
158
|
+
await flush()
|
|
159
|
+
expect(client.send('unknown', { n: 1 })).toBe(false)
|
|
160
|
+
await flush()
|
|
161
|
+
expect(harness.posts).toEqual([])
|
|
162
|
+
client.stop()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('reconnects with backoff when the stream ends, and announces the new connection', async () => {
|
|
166
|
+
vi.useFakeTimers()
|
|
167
|
+
const { client, harness } = startClient()
|
|
168
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
169
|
+
harness.streams[0]?.emit('ready', READY)
|
|
170
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
171
|
+
harness.streams[0]?.end()
|
|
172
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
173
|
+
expect(client.isConnected()).toBe(false)
|
|
174
|
+
expect(harness.statuses).toEqual(['connecting', 'connected', 'reconnecting'])
|
|
175
|
+
await vi.advanceTimersByTimeAsync(CHANNEL_RECONNECT_DELAYS_MS[0])
|
|
176
|
+
harness.streams[1]?.emit('ready', READY)
|
|
177
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
178
|
+
expect(harness.streams).toHaveLength(2)
|
|
179
|
+
expect(harness.ready).toHaveBeenCalledTimes(2)
|
|
180
|
+
expect(harness.statuses.at(-1)).toBe('connected')
|
|
181
|
+
client.stop()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('drops the connection and reconnects when a post fails', async () => {
|
|
185
|
+
vi.useFakeTimers()
|
|
186
|
+
const post = vi.fn(async () => {
|
|
187
|
+
throw new Error('404')
|
|
188
|
+
})
|
|
189
|
+
const { client, harness } = startClient(post)
|
|
190
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
191
|
+
harness.streams[0]?.emit('ready', READY)
|
|
192
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
193
|
+
client.send('chat', { n: 1 })
|
|
194
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
195
|
+
expect(client.getStatus()).toBe('reconnecting')
|
|
196
|
+
await vi.advanceTimersByTimeAsync(CHANNEL_RECONNECT_DELAYS_MS[0])
|
|
197
|
+
expect(harness.streams).toHaveLength(2)
|
|
198
|
+
client.stop()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('says connecting while reconnectNow replaces a live stream, until the new one is ready', async () => {
|
|
202
|
+
const { client, harness } = startClient()
|
|
203
|
+
await flush()
|
|
204
|
+
harness.streams[0]?.emit('ready', READY)
|
|
205
|
+
await flush()
|
|
206
|
+
client.reconnectNow()
|
|
207
|
+
await flush()
|
|
208
|
+
expect(client.getStatus()).toBe('connecting')
|
|
209
|
+
expect(client.send('chat', { n: 1 })).toBe(false)
|
|
210
|
+
harness.streams[1]?.emit('ready', READY)
|
|
211
|
+
await flush()
|
|
212
|
+
expect(harness.statuses).toEqual(['connecting', 'connected', 'connecting', 'connected'])
|
|
213
|
+
expect(harness.ready).toHaveBeenCalledTimes(2)
|
|
214
|
+
client.stop()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('skips the backoff on reconnectNow', async () => {
|
|
218
|
+
vi.useFakeTimers()
|
|
219
|
+
const { client, harness } = startClient()
|
|
220
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
221
|
+
harness.streams[0]?.end()
|
|
222
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
223
|
+
client.reconnectNow()
|
|
224
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
225
|
+
expect(harness.streams).toHaveLength(2)
|
|
226
|
+
client.stop()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('stops reconnecting once stopped, and opens again on start', async () => {
|
|
230
|
+
vi.useFakeTimers()
|
|
231
|
+
const { client, harness } = startClient()
|
|
232
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
233
|
+
client.stop()
|
|
234
|
+
await vi.advanceTimersByTimeAsync(
|
|
235
|
+
CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1] * 2,
|
|
236
|
+
)
|
|
237
|
+
expect(harness.streams).toHaveLength(1)
|
|
238
|
+
expect(client.getStatus()).toBe('disconnected')
|
|
239
|
+
client.reconnectNow()
|
|
240
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
241
|
+
expect(harness.streams).toHaveLength(1)
|
|
242
|
+
client.start()
|
|
243
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
244
|
+
expect(harness.streams).toHaveLength(2)
|
|
245
|
+
client.stop()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createChatTransport,
|
|
4
|
+
exposeChatTransport,
|
|
5
|
+
} from '../app/runtime/chat-transport'
|
|
6
|
+
import { CHAT_TRANSPORT_KEY } from '../app/runtime/constants'
|
|
7
|
+
|
|
8
|
+
function hub() {
|
|
9
|
+
const actions = {
|
|
10
|
+
send: vi.fn(() => true),
|
|
11
|
+
reconnect: vi.fn(),
|
|
12
|
+
getStatus: vi.fn(() => 'connected' as const),
|
|
13
|
+
}
|
|
14
|
+
return { actions, chat: createChatTransport(actions) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('chat transport', () => {
|
|
18
|
+
it('lends the chat its channel: frames, readiness and status in, messages out', () => {
|
|
19
|
+
const { actions, chat } = hub()
|
|
20
|
+
const frames: string[] = []
|
|
21
|
+
const statuses: string[] = []
|
|
22
|
+
const ready = vi.fn()
|
|
23
|
+
chat.transport.onFrame((raw) => frames.push(raw))
|
|
24
|
+
chat.transport.onStatus((status) => statuses.push(status))
|
|
25
|
+
chat.transport.onReady(ready)
|
|
26
|
+
chat.deliverFrame('{"type":"run_done"}')
|
|
27
|
+
chat.announceStatus('reconnecting')
|
|
28
|
+
chat.announceReady()
|
|
29
|
+
expect(chat.transport.send({ type: 'hello' })).toBe(true)
|
|
30
|
+
chat.transport.reconnect()
|
|
31
|
+
expect(frames).toEqual(['{"type":"run_done"}'])
|
|
32
|
+
expect(statuses).toEqual(['reconnecting'])
|
|
33
|
+
expect(ready).toHaveBeenCalledTimes(1)
|
|
34
|
+
expect(actions.send).toHaveBeenCalledWith({ type: 'hello' })
|
|
35
|
+
expect(actions.reconnect).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(chat.transport.getStatus()).toBe('connected')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('stops delivering to a listener once it unsubscribed', () => {
|
|
40
|
+
const { chat } = hub()
|
|
41
|
+
const frames: string[] = []
|
|
42
|
+
const unsubscribe = chat.transport.onFrame((raw) => frames.push(raw))
|
|
43
|
+
unsubscribe()
|
|
44
|
+
chat.deliverFrame('{}')
|
|
45
|
+
expect(frames).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps delivering when one listener throws, as a gone chat document would', () => {
|
|
49
|
+
const { chat } = hub()
|
|
50
|
+
const frames: string[] = []
|
|
51
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
52
|
+
chat.transport.onFrame(() => {
|
|
53
|
+
throw new Error('dead document')
|
|
54
|
+
})
|
|
55
|
+
chat.transport.onFrame((raw) => frames.push(raw))
|
|
56
|
+
chat.deliverFrame('{}')
|
|
57
|
+
expect(frames).toEqual(['{}'])
|
|
58
|
+
error.mockRestore()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('publishes itself where the same-origin chat document looks', () => {
|
|
62
|
+
const { chat } = hub()
|
|
63
|
+
exposeChatTransport(chat.transport)
|
|
64
|
+
expect(Reflect.get(globalThis, CHAT_TRANSPORT_KEY)).toBe(chat.transport)
|
|
65
|
+
Reflect.deleteProperty(globalThis, CHAT_TRANSPORT_KEY)
|
|
66
|
+
})
|
|
67
|
+
})
|