@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.
Files changed (145) hide show
  1. package/README.md +49 -0
  2. package/dist/channels/bridge.d.ts +22 -0
  3. package/dist/channels/bridge.js +87 -0
  4. package/dist/channels/bridge.js.map +1 -0
  5. package/dist/channels/channel-service.d.ts +5 -0
  6. package/dist/channels/channel-service.js +68 -0
  7. package/dist/channels/channel-service.js.map +1 -0
  8. package/dist/channels/message-budget.d.ts +3 -0
  9. package/dist/channels/message-budget.js +29 -0
  10. package/dist/channels/message-budget.js.map +1 -0
  11. package/dist/channels/registry.d.ts +7 -0
  12. package/dist/channels/registry.js +47 -0
  13. package/dist/channels/registry.js.map +1 -0
  14. package/dist/channels/sse-stream.d.ts +19 -0
  15. package/dist/channels/sse-stream.js +59 -0
  16. package/dist/channels/sse-stream.js.map +1 -0
  17. package/dist/chatbox/passthrough.d.ts +3 -0
  18. package/dist/chatbox/passthrough.js +53 -0
  19. package/dist/chatbox/passthrough.js.map +1 -0
  20. package/dist/constants/channels.d.ts +30 -0
  21. package/dist/constants/channels.js +38 -0
  22. package/dist/constants/channels.js.map +1 -0
  23. package/dist/constants/chatbox.d.ts +10 -0
  24. package/dist/constants/chatbox.js +35 -0
  25. package/dist/constants/chatbox.js.map +1 -0
  26. package/dist/constants/sidecar.d.ts +4 -0
  27. package/dist/constants/sidecar.js +3 -1
  28. package/dist/constants/sidecar.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +5 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lifecycle/sidecar-socket.d.ts +2 -0
  33. package/dist/lifecycle/sidecar-socket.js +37 -0
  34. package/dist/lifecycle/sidecar-socket.js.map +1 -0
  35. package/dist/lifecycle/sidecar-unavailable-error.d.ts +3 -0
  36. package/dist/lifecycle/sidecar-unavailable-error.js +10 -0
  37. package/dist/lifecycle/sidecar-unavailable-error.js.map +1 -0
  38. package/dist/pages/settings/page.js.map +1 -1
  39. package/dist/routes/channels.d.ts +8 -0
  40. package/dist/routes/channels.js +52 -0
  41. package/dist/routes/channels.js.map +1 -0
  42. package/dist/routes/chatbox.d.ts +7 -0
  43. package/dist/routes/chatbox.js +47 -0
  44. package/dist/routes/chatbox.js.map +1 -0
  45. package/dist/routes/index.d.ts +2 -0
  46. package/dist/routes/index.js +2 -0
  47. package/dist/routes/index.js.map +1 -1
  48. package/dist/routes/sidecar-info.js +0 -2
  49. package/dist/routes/sidecar-info.js.map +1 -1
  50. package/frontend-vue/app/components/SettingsView.vue +19 -22
  51. package/frontend-vue/app/plugins/ai.client.ts +137 -58
  52. package/frontend-vue/app/runtime/channel-client.ts +265 -0
  53. package/frontend-vue/app/runtime/chat-transport.ts +82 -0
  54. package/frontend-vue/app/runtime/constants.ts +31 -15
  55. package/frontend-vue/app/runtime/current-page.ts +9 -5
  56. package/frontend-vue/app/runtime/event-stream.ts +75 -0
  57. package/frontend-vue/app/runtime/overlay.ts +23 -6
  58. package/frontend-vue/app/runtime/sidecar-status.ts +10 -25
  59. package/frontend-vue/app/runtime/theme-bridge.ts +16 -4
  60. package/frontend-vue/package.json +3 -0
  61. package/frontend-vue/tests/backoff.test.ts +15 -15
  62. package/frontend-vue/tests/channel-client.test.ts +247 -0
  63. package/frontend-vue/tests/chat-transport.test.ts +67 -0
  64. package/frontend-vue/tests/sidecar-status.test.ts +39 -0
  65. package/package.json +5 -3
  66. package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +1 -0
  67. package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +52 -0
  68. package/sidecar/chatbox/dist/index.html +2 -2
  69. package/sidecar/dist/agent/disposals.d.ts +5 -0
  70. package/sidecar/dist/agent/disposals.js +17 -0
  71. package/sidecar/dist/agent/disposals.js.map +1 -0
  72. package/sidecar/dist/agent/effective-mode.d.ts +2 -0
  73. package/sidecar/dist/agent/effective-mode.js +20 -0
  74. package/sidecar/dist/agent/effective-mode.js.map +1 -0
  75. package/sidecar/dist/agent/provider.d.ts +2 -2
  76. package/sidecar/dist/agent/runner-events.d.ts +9 -1
  77. package/sidecar/dist/agent/runner.d.ts +2 -2
  78. package/sidecar/dist/agent/runner.js +11 -5
  79. package/sidecar/dist/agent/runner.js.map +1 -1
  80. package/sidecar/dist/agent/session.d.ts +3 -3
  81. package/sidecar/dist/agent/session.js +54 -34
  82. package/sidecar/dist/agent/session.js.map +1 -1
  83. package/sidecar/dist/agent/switching-runner.js +16 -14
  84. package/sidecar/dist/agent/switching-runner.js.map +1 -1
  85. package/sidecar/dist/agent/turn-end-reasons.d.ts +3 -0
  86. package/sidecar/dist/agent/turn-end-reasons.js +24 -0
  87. package/sidecar/dist/agent/turn-end-reasons.js.map +1 -0
  88. package/sidecar/dist/constants/agent.d.ts +6 -1
  89. package/sidecar/dist/constants/agent.js +13 -1
  90. package/sidecar/dist/constants/agent.js.map +1 -1
  91. package/sidecar/dist/constants/claude.d.ts +27 -1
  92. package/sidecar/dist/constants/claude.js +49 -0
  93. package/sidecar/dist/constants/claude.js.map +1 -1
  94. package/sidecar/dist/constants/codex.d.ts +6 -0
  95. package/sidecar/dist/constants/codex.js +13 -0
  96. package/sidecar/dist/constants/codex.js.map +1 -1
  97. package/sidecar/dist/constants/settings.js.map +1 -1
  98. package/sidecar/dist/constants/timing.d.ts +1 -0
  99. package/sidecar/dist/constants/timing.js +1 -0
  100. package/sidecar/dist/constants/timing.js.map +1 -1
  101. package/sidecar/dist/protocol/events.d.ts +36 -0
  102. package/sidecar/dist/protocol/events.js +13 -0
  103. package/sidecar/dist/protocol/events.js.map +1 -1
  104. package/sidecar/dist/providers/claude/activity.d.ts +5 -0
  105. package/sidecar/dist/providers/claude/activity.js +63 -0
  106. package/sidecar/dist/providers/claude/activity.js.map +1 -0
  107. package/sidecar/dist/providers/claude/adapter.js +21 -13
  108. package/sidecar/dist/providers/claude/adapter.js.map +1 -1
  109. package/sidecar/dist/providers/claude/config.d.ts +2 -1
  110. package/sidecar/dist/providers/claude/config.js +4 -0
  111. package/sidecar/dist/providers/claude/config.js.map +1 -1
  112. package/sidecar/dist/providers/claude/provider.js +18 -25
  113. package/sidecar/dist/providers/claude/provider.js.map +1 -1
  114. package/sidecar/dist/providers/claude/result.d.ts +3 -0
  115. package/sidecar/dist/providers/claude/result.js +61 -0
  116. package/sidecar/dist/providers/claude/result.js.map +1 -0
  117. package/sidecar/dist/providers/claude/safe-mode.d.ts +4 -0
  118. package/sidecar/dist/providers/claude/safe-mode.js +24 -0
  119. package/sidecar/dist/providers/claude/safe-mode.js.map +1 -0
  120. package/sidecar/dist/providers/codex/adapter.js +8 -2
  121. package/sidecar/dist/providers/codex/adapter.js.map +1 -1
  122. package/sidecar/dist/providers/codex/client.d.ts +1 -0
  123. package/sidecar/dist/providers/codex/client.js +1 -0
  124. package/sidecar/dist/providers/codex/client.js.map +1 -1
  125. package/sidecar/dist/providers/codex/config.js +2 -1
  126. package/sidecar/dist/providers/codex/config.js.map +1 -1
  127. package/sidecar/dist/providers/codex/process.js +113 -33
  128. package/sidecar/dist/providers/codex/process.js.map +1 -1
  129. package/sidecar/dist/providers/codex/provider.js +6 -4
  130. package/sidecar/dist/providers/codex/provider.js.map +1 -1
  131. package/sidecar/dist/server/routing.js +39 -21
  132. package/sidecar/dist/server/routing.js.map +1 -1
  133. package/sidecar/dist/server/turn-progress.d.ts +13 -0
  134. package/sidecar/dist/server/turn-progress.js +72 -0
  135. package/sidecar/dist/server/turn-progress.js.map +1 -0
  136. package/sidecar/dist/server/ws.js +4 -2
  137. package/sidecar/dist/server/ws.js.map +1 -1
  138. package/sidecar/dist/state/types.d.ts +2 -1
  139. package/sidecar/dist/state/types.js +1 -0
  140. package/sidecar/dist/state/types.js.map +1 -1
  141. package/sidecar/package.json +1 -1
  142. package/frontend-vue/app/runtime/ws-client.ts +0 -196
  143. package/frontend-vue/tests/sidecar-auth.test.ts +0 -87
  144. package/sidecar/chatbox/dist/assets/index-CEZ1NLp9.js +0 -52
  145. package/sidecar/chatbox/dist/assets/index-D_51Vqqk.css +0 -1
@@ -4,85 +4,131 @@ import {
4
4
  useDmsRouter,
5
5
  } from '#dms/frontend-module'
6
6
  import {
7
- SIDECAR_HOST_NAME,
7
+ type ChannelClient,
8
+ createChannelClient,
9
+ } from '../runtime/channel-client'
10
+ import {
11
+ createChatTransport,
12
+ exposeChatTransport,
13
+ } from '../runtime/chat-transport'
14
+ import {
15
+ CHANNEL_IDLE_STOP_MS,
16
+ CHANNEL_STATUS_RECONNECTING,
17
+ CHAT_CHANNEL,
18
+ CHATBOX_PATH,
19
+ HELLO_MESSAGE_TYPE,
20
+ HOST_CHANNEL,
21
+ HOST_ROLE,
22
+ JSON_CONTENT_TYPE,
8
23
  SIDECAR_INFO_PATH,
9
- SIDECAR_PROBE_HOST,
10
24
  SIDECAR_STATUS_CONNECTED,
11
25
  SIDECAR_STATUS_REVIVING,
12
26
  SIDECAR_STATUS_UNAVAILABLE,
13
- WS_STATUS_CONNECTED,
14
- WS_STATUS_CONNECTING,
27
+ VISIBILITY_CHANGE_EVENT,
15
28
  } from '../runtime/constants'
16
- import { installCurrentPageTracker } from '../runtime/current-page'
29
+ import {
30
+ buildCurrentPageUpdate,
31
+ installCurrentPageTracker,
32
+ } from '../runtime/current-page'
17
33
  import { registerLauncherAction } from '../runtime/header-action'
18
- import { createHostCommandDispatcher } from '../runtime/host-commands'
34
+ import {
35
+ createHostCommandDispatcher,
36
+ type HostCommandDispatcher,
37
+ } from '../runtime/host-commands'
19
38
  import { installNavigationCompleteEmitter } from '../runtime/navigation-complete'
20
- import { injectOverlay } from '../runtime/overlay'
39
+ import { injectOverlay, type OverlayHandle } from '../runtime/overlay'
21
40
  import { runWhenLoggedIn } from '../runtime/session-gate'
22
41
  import { installToggleShortcut } from '../runtime/shortcuts'
23
42
  import {
24
43
  createSidecarStatusController,
25
44
  type SidecarStatus,
45
+ type SidecarStatusController,
26
46
  } from '../runtime/sidecar-status'
27
- import { createHostWsClient } from '../runtime/ws-client'
28
47
 
29
- function buildIframeUrl(port: number, token: string): string {
48
+ type AuthFetch = ReturnType<typeof useAuthFetch>['$authFetch']
49
+
50
+ interface ChannelDeps {
51
+ $authFetch: AuthFetch
52
+ controller: SidecarStatusController
53
+ dispatchHostCommand: HostCommandDispatcher
54
+ }
55
+
56
+ function buildIframeUrl(): string {
30
57
  // Seed the chatbox with the host's current light/dark mode so its very first
31
58
  // paint matches; the theme bridge keeps it in sync from there on.
32
59
  const mode = document.documentElement.classList.contains('dark')
33
60
  ? 'dark'
34
61
  : 'light'
35
- return `${SIDECAR_PROBE_HOST}:${port}/?theme=${mode}#token=${encodeURIComponent(token)}`
62
+ return `${CHATBOX_PATH}?theme=${mode}`
36
63
  }
37
64
 
38
- async function startAssistant({ vueApp }: DmsAppContext): Promise<void> {
39
- const { $authFetch } = useAuthFetch()
40
- const controller = createSidecarStatusController(() =>
41
- $authFetch(SIDECAR_INFO_PATH),
42
- )
43
- // A null/disabled first probe means the assistant shouldn't appear at all —
44
- // preserve the previous "no icon, no overlay" behavior in that case.
45
- if (!(await controller.init())) return
46
- const overlay = injectOverlay()
47
- if (overlay === null) return
48
-
49
- installToggleShortcut({ onToggle: () => overlay.toggleOpen() })
50
- // Registered unconditionally (even while reviving) so the launcher is present
51
- // through an outage; opening it surfaces the panel's own status placeholder.
52
- registerLauncherAction(() => overlay.toggleFromLauncher())
53
-
54
- const router = useDmsRouter()
55
- // Host-side dev hot reload. A page the agent just wrote is re-registered
56
- // asynchronously, so every overlay-driven navigation waits for the committed
57
- // site layout to serve the route before pushing it.
58
- const devReload = useDmsDevReload()
59
- // Serialized so a burst of navigate commands cannot land out of order.
60
- const dispatchHostCommand = createHostCommandDispatcher({ router, devReload })
61
- const hostWs = createHostWsClient({
62
- sidecarHost: SIDECAR_HOST_NAME,
63
- getPort: () => controller.getPort(),
64
- getClientToken: () => controller.getClientToken(),
65
- onMessage: dispatchHostCommand,
65
+ function createAssistantChannel(deps: ChannelDeps): ChannelClient {
66
+ const chat = createChatTransport({
67
+ send: (msg) => channel.send(CHAT_CHANNEL, msg),
68
+ reconnect: () => channel.reconnectNow(),
69
+ getStatus: () => channel.getStatus(),
70
+ })
71
+ const frameRoutes: Record<string, (raw: string) => void> = {
72
+ [HOST_CHANNEL]: deps.dispatchHostCommand,
73
+ [CHAT_CHANNEL]: chat.deliverFrame,
74
+ }
75
+ const channel = createChannelClient({
76
+ channels: [HOST_CHANNEL, CHAT_CHANNEL],
77
+ post: (path, body) =>
78
+ deps.$authFetch(path, {
79
+ method: 'POST',
80
+ body: JSON.stringify(body),
81
+ headers: { 'content-type': JSON_CONTENT_TYPE },
82
+ }),
83
+ onReady: () => {
84
+ channel.send(HOST_CHANNEL, { type: HELLO_MESSAGE_TYPE, role: HOST_ROLE })
85
+ channel.send(HOST_CHANNEL, buildCurrentPageUpdate(window.location.href))
86
+ chat.announceReady()
87
+ },
88
+ onFrame: (name, raw) => frameRoutes[name]?.(raw),
66
89
  onStatusChange: (status) => {
67
- // A socket drop means the sidecar likely idle-exited/crashed: ask the
68
- // controller to re-probe (relearning a possibly-new port) rather than let
69
- // the socket keep retrying the dead one.
70
- if (status !== WS_STATUS_CONNECTED && status !== WS_STATUS_CONNECTING) {
71
- controller.reportWsDown()
72
- }
90
+ chat.announceStatus(status)
91
+ // The sidecar went away, or dms-ai reloaded: re-probe, which revives
92
+ // an idle-exited sidecar, rather than only waiting on the retries.
93
+ if (status === CHANNEL_STATUS_RECONNECTING) deps.controller.reportChannelDown()
73
94
  },
74
95
  })
96
+ exposeChatTransport(chat.transport)
97
+ return channel
98
+ }
75
99
 
100
+ function followPanel(overlay: OverlayHandle, channel: ChannelClient): () => void {
101
+ let stopTimer: ReturnType<typeof setTimeout> | null = null
102
+ const clearStopTimer = (): void => {
103
+ if (stopTimer !== null) clearTimeout(stopTimer)
104
+ stopTimer = null
105
+ }
106
+ const follow = (): void => {
107
+ clearStopTimer()
108
+ const isNeeded = overlay.isOpen() && document.visibilityState === 'visible'
109
+ if (isNeeded) channel.start()
110
+ else stopTimer = setTimeout(() => channel.stop(), CHANNEL_IDLE_STOP_MS)
111
+ }
112
+ const stopOpenChange = overlay.onOpenChange(follow)
113
+ document.addEventListener(VISIBILITY_CHANGE_EVENT, follow)
114
+ follow()
115
+ return () => {
116
+ clearStopTimer()
117
+ stopOpenChange()
118
+ document.removeEventListener(VISIBILITY_CHANGE_EVENT, follow)
119
+ }
120
+ }
121
+
122
+ function statusApplier(
123
+ overlay: OverlayHandle,
124
+ channel: ChannelClient,
125
+ ): (status: SidecarStatus) => void {
76
126
  let firstApply = true
77
- const applyStatus = (status: SidecarStatus): void => {
127
+ return (status) => {
78
128
  if (status === SIDECAR_STATUS_CONNECTED) {
79
- const port = controller.getPort()
80
- const token = controller.getClientToken()
81
- if (port !== null && token) overlay.repoint(buildIframeUrl(port, token))
82
- // On the first apply the socket was just created and is connecting to the
83
- // right port; only later connects (after a drop or port change) need a
84
- // forced, backoff-skipping reconnect.
85
- if (!firstApply) hostWs.reconnectNow()
129
+ overlay.repoint(buildIframeUrl())
130
+ // Only a connect after a drop needs to skip the channel's backoff.
131
+ if (!firstApply) channel.reconnectNow()
86
132
  } else if (status === SIDECAR_STATUS_REVIVING) {
87
133
  overlay.showPlaceholder('reviving')
88
134
  } else if (status === SIDECAR_STATUS_UNAVAILABLE) {
@@ -92,22 +138,55 @@ async function startAssistant({ vueApp }: DmsAppContext): Promise<void> {
92
138
  }
93
139
  firstApply = false
94
140
  }
95
- controller.subscribe(applyStatus)
141
+ }
96
142
 
143
+ function installHostState(channel: ChannelClient): () => void {
97
144
  const sendWhenConnected = (msg: unknown): void => {
98
- if (!hostWs.isConnected()) return
99
- hostWs.send(msg)
145
+ if (!channel.isConnected()) return
146
+ channel.send(HOST_CHANNEL, msg)
100
147
  }
101
148
  const stopTracking = installCurrentPageTracker({ send: sendWhenConnected })
102
149
  const stopNavigation = installNavigationCompleteEmitter({
103
150
  send: sendWhenConnected,
104
151
  })
105
- const stop = (): void => {
152
+ return () => {
106
153
  stopTracking()
107
154
  stopNavigation()
108
155
  }
109
- vueApp.onUnmount(stop)
110
- import.meta.hot?.dispose(stop)
156
+ }
157
+
158
+ async function startAssistant({ vueApp }: DmsAppContext): Promise<void> {
159
+ const { $authFetch } = useAuthFetch()
160
+ const controller = createSidecarStatusController(() =>
161
+ $authFetch(SIDECAR_INFO_PATH),
162
+ )
163
+ // A null/disabled first probe means the assistant shouldn't appear at all —
164
+ // preserve the previous "no icon, no overlay" behavior in that case.
165
+ if (!(await controller.init())) return
166
+ const overlay = injectOverlay()
167
+ if (overlay === null) return
168
+ installToggleShortcut({ onToggle: () => overlay.toggleOpen() })
169
+ // Registered unconditionally (even while reviving) so the launcher is present
170
+ // through an outage; opening it surfaces the panel's own status placeholder.
171
+ registerLauncherAction(() => overlay.toggleFromLauncher())
172
+ // Host-side dev hot reload: a page the agent just wrote is re-registered
173
+ // asynchronously, so each navigation waits for the layout to serve it, one at
174
+ // a time so a burst of commands cannot land out of order.
175
+ const dispatchHostCommand = createHostCommandDispatcher({
176
+ router: useDmsRouter(),
177
+ devReload: useDmsDevReload(),
178
+ })
179
+ const channel = createAssistantChannel({ $authFetch, controller, dispatchHostCommand })
180
+ controller.subscribe(statusApplier(overlay, channel))
181
+ const stopFollowing = followPanel(overlay, channel)
182
+ const stopHostState = installHostState(channel)
183
+ const teardown = (): void => {
184
+ stopFollowing()
185
+ stopHostState()
186
+ channel.stop()
187
+ }
188
+ vueApp.onUnmount(teardown)
189
+ import.meta.hot?.dispose(teardown)
111
190
  }
112
191
 
113
192
  export default defineDmsPlugin((context) => {
@@ -0,0 +1,265 @@
1
+ import {
2
+ type BackoffState,
3
+ createBackoff,
4
+ nextDelay,
5
+ resetBackoff,
6
+ } from './backoff'
7
+ import {
8
+ CHANNEL_EVENT_READY,
9
+ CHANNEL_EVENTS_SEGMENT,
10
+ CHANNEL_LIST_SEPARATOR,
11
+ CHANNEL_MESSAGES_SEGMENT,
12
+ CHANNEL_RECONNECT_DELAYS_MS,
13
+ CHANNEL_STATUS_CONNECTED,
14
+ CHANNEL_STATUS_CONNECTING,
15
+ CHANNEL_STATUS_DISCONNECTED,
16
+ CHANNEL_STATUS_RECONNECTING,
17
+ CHANNELS_PATH,
18
+ EVENT_STREAM_TYPE,
19
+ } from './constants'
20
+ import { readEventStream, type StreamEvent } from './event-stream'
21
+
22
+ export type ChannelStatus =
23
+ | typeof CHANNEL_STATUS_CONNECTING
24
+ | typeof CHANNEL_STATUS_CONNECTED
25
+ | typeof CHANNEL_STATUS_DISCONNECTED
26
+ | typeof CHANNEL_STATUS_RECONNECTING
27
+
28
+ /** Posts one message as JSON; `$authFetch` in the dashboard refreshes a stale session. */
29
+ export type PostMessage = (path: string, body: unknown) => Promise<unknown>
30
+
31
+ /** Opens the event stream of a set of channels. */
32
+ export type OpenStream = (path: string, signal: AbortSignal) => Promise<Response>
33
+
34
+ export interface ChannelClientOptions {
35
+ channels: readonly string[]
36
+ post: PostMessage
37
+ openStream?: OpenStream
38
+ /** Every connection is a new sidecar socket per channel: identify on it here. */
39
+ onReady?: () => void
40
+ /** Raw sidecar frame, as the socket of that channel delivered it. */
41
+ onFrame?: (channel: string, raw: string) => void
42
+ onStatusChange?: (status: ChannelStatus) => void
43
+ }
44
+
45
+ export interface ChannelClient {
46
+ /**
47
+ * Queued and posted one at a time, in order. False when it was dropped
48
+ * because the stream is not connected, so the caller knows nothing left.
49
+ */
50
+ send: (channel: string, msg: unknown) => boolean
51
+ isConnected: () => boolean
52
+ getStatus: () => ChannelStatus
53
+ /** Opens the stream if it is not open yet. */
54
+ start: () => void
55
+ /** Closes the stream until the next `start`. */
56
+ stop: () => void
57
+ /**
58
+ * Drops any pending backoff and replaces the stream at once, if started.
59
+ * The status says connecting until the new stream is ready, since nothing
60
+ * can be sent in between.
61
+ */
62
+ reconnectNow: () => void
63
+ }
64
+
65
+ interface ReadyPayload {
66
+ connections?: Record<string, unknown>
67
+ }
68
+
69
+ interface QueuedMessage {
70
+ channel: string
71
+ msg: unknown
72
+ }
73
+
74
+ interface ClientState {
75
+ options: ChannelClientOptions
76
+ status: ChannelStatus
77
+ connections: Record<string, string> | null
78
+ stream: AbortController | null
79
+ retryTimer: ReturnType<typeof setTimeout> | null
80
+ queue: QueuedMessage[]
81
+ isDraining: boolean
82
+ isStarted: boolean
83
+ backoff: BackoffState
84
+ }
85
+
86
+ export function channelEventsPath(channels: readonly string[]): string {
87
+ return `${CHANNELS_PATH}/${channels.join(CHANNEL_LIST_SEPARATOR)}/${CHANNEL_EVENTS_SEGMENT}`
88
+ }
89
+
90
+ export function channelMessagesPath(connectionId: string): string {
91
+ return `${CHANNELS_PATH}/${encodeURIComponent(connectionId)}/${CHANNEL_MESSAGES_SEGMENT}`
92
+ }
93
+
94
+ function openEventStream(path: string, signal: AbortSignal): Promise<Response> {
95
+ return fetch(path, {
96
+ headers: { accept: EVENT_STREAM_TYPE },
97
+ credentials: 'same-origin',
98
+ cache: 'no-store',
99
+ signal,
100
+ })
101
+ }
102
+
103
+ function setStatus(state: ClientState, next: ChannelStatus): void {
104
+ if (state.status === next) return
105
+ state.status = next
106
+ state.options.onStatusChange?.(next)
107
+ }
108
+
109
+ function readConnections(data: string): Record<string, string> | null {
110
+ try {
111
+ const { connections } = JSON.parse(data) as ReadyPayload
112
+ if (connections === undefined || connections === null) return null
113
+ const entries = Object.entries(connections).filter(
114
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
115
+ )
116
+ return Object.fromEntries(entries)
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ function handleReady(state: ClientState, event: StreamEvent): void {
123
+ state.connections = readConnections(event.data)
124
+ resetBackoff(state.backoff)
125
+ setStatus(state, CHANNEL_STATUS_CONNECTED)
126
+ state.options.onReady?.()
127
+ }
128
+
129
+ function handleEvent(state: ClientState, event: StreamEvent): void {
130
+ if (event.name === CHANNEL_EVENT_READY) return handleReady(state, event)
131
+ if (!state.options.channels.includes(event.name)) return
132
+ state.options.onFrame?.(event.name, event.data)
133
+ }
134
+
135
+ function scheduleRetry(state: ClientState): void {
136
+ if (state.retryTimer !== null) return
137
+ const delay = nextDelay(state.backoff, CHANNEL_RECONNECT_DELAYS_MS)
138
+ state.retryTimer = setTimeout(() => {
139
+ state.retryTimer = null
140
+ connect(state)
141
+ }, delay)
142
+ }
143
+
144
+ function lose(state: ClientState): void {
145
+ state.connections = null
146
+ state.queue = []
147
+ if (!state.isStarted) return
148
+ setStatus(state, CHANNEL_STATUS_RECONNECTING)
149
+ scheduleRetry(state)
150
+ }
151
+
152
+ async function run(state: ClientState, controller: AbortController): Promise<void> {
153
+ const open = state.options.openStream ?? openEventStream
154
+ try {
155
+ const response = await open(
156
+ channelEventsPath(state.options.channels),
157
+ controller.signal,
158
+ )
159
+ if (!response.ok || response.body === null) return
160
+ await readEventStream(response.body, (event) => handleEvent(state, event))
161
+ } catch {
162
+ return
163
+ } finally {
164
+ if (!controller.signal.aborted) lose(state)
165
+ }
166
+ }
167
+
168
+ function connect(state: ClientState): void {
169
+ if (!state.isStarted) return
170
+ state.stream?.abort()
171
+ state.connections = null
172
+ const controller = new AbortController()
173
+ state.stream = controller
174
+ void run(state, controller)
175
+ }
176
+
177
+ function restart(state: ClientState): void {
178
+ state.stream?.abort()
179
+ lose(state)
180
+ }
181
+
182
+ async function drain(state: ClientState): Promise<void> {
183
+ if (state.isDraining) return
184
+ state.isDraining = true
185
+ while (state.queue.length > 0 && state.connections !== null) {
186
+ const next = state.queue.shift() as QueuedMessage
187
+ const connectionId = state.connections[next.channel]
188
+ if (connectionId === undefined) continue
189
+ try {
190
+ await state.options.post(channelMessagesPath(connectionId), next.msg)
191
+ } catch {
192
+ restart(state)
193
+ }
194
+ }
195
+ state.isDraining = false
196
+ }
197
+
198
+ function send(state: ClientState, channel: string, msg: unknown): boolean {
199
+ if (state.connections?.[channel] === undefined) return false
200
+ state.queue.push({ channel, msg })
201
+ void drain(state)
202
+ return true
203
+ }
204
+
205
+ function clearRetry(state: ClientState): void {
206
+ if (state.retryTimer !== null) clearTimeout(state.retryTimer)
207
+ state.retryTimer = null
208
+ }
209
+
210
+ function start(state: ClientState): void {
211
+ if (state.isStarted) return
212
+ state.isStarted = true
213
+ resetBackoff(state.backoff)
214
+ setStatus(state, CHANNEL_STATUS_CONNECTING)
215
+ connect(state)
216
+ }
217
+
218
+ function stop(state: ClientState): void {
219
+ if (!state.isStarted) return
220
+ state.isStarted = false
221
+ clearRetry(state)
222
+ state.stream?.abort()
223
+ state.connections = null
224
+ state.queue = []
225
+ setStatus(state, CHANNEL_STATUS_DISCONNECTED)
226
+ }
227
+
228
+ function reconnectNow(state: ClientState): void {
229
+ if (!state.isStarted) return
230
+ clearRetry(state)
231
+ resetBackoff(state.backoff)
232
+ setStatus(state, CHANNEL_STATUS_CONNECTING)
233
+ connect(state)
234
+ }
235
+
236
+ /**
237
+ * One stream to the sidecar through the DMS carrying several channels, and
238
+ * sequential POSTs to send on each. Browsers keep at most six HTTP/1.1
239
+ * connections per origin, so a tab holds one such stream and only while it
240
+ * needs it: the caller starts and stops it. While started, any end of the
241
+ * stream — a sidecar that went down, a hot reload of dms-ai, a network drop —
242
+ * reconnects with backoff, and each new connection is announced through
243
+ * `onReady` so the caller identifies itself again.
244
+ */
245
+ export function createChannelClient(options: ChannelClientOptions): ChannelClient {
246
+ const state: ClientState = {
247
+ options,
248
+ status: CHANNEL_STATUS_DISCONNECTED,
249
+ connections: null,
250
+ stream: null,
251
+ retryTimer: null,
252
+ queue: [],
253
+ isDraining: false,
254
+ isStarted: false,
255
+ backoff: createBackoff(),
256
+ }
257
+ return {
258
+ send: (channel, msg) => send(state, channel, msg),
259
+ isConnected: () => state.connections !== null,
260
+ getStatus: () => state.status,
261
+ start: () => start(state),
262
+ stop: () => stop(state),
263
+ reconnectNow: () => reconnectNow(state),
264
+ }
265
+ }
@@ -0,0 +1,82 @@
1
+ import type { ChannelStatus } from './channel-client'
2
+ import { CHAT_TRANSPORT_KEY, LOG_PREFIX } from './constants'
3
+
4
+ /**
5
+ * What the chat document, same-origin with the dashboard, reads on its parent
6
+ * window to reach the sidecar: the dashboard owns the tab's only stream and
7
+ * lends the chat its channel of it.
8
+ */
9
+ export interface ChatTransport {
10
+ /** Raw sidecar frames of the chat channel, in order. */
11
+ onFrame: (listener: (raw: string) => void) => () => void
12
+ /** Every (re)connection is a new sidecar socket: the chat says hello again. */
13
+ onReady: (listener: () => void) => () => void
14
+ onStatus: (listener: (status: ChannelStatus) => void) => () => void
15
+ getStatus: () => ChannelStatus
16
+ /** False when the message was dropped because the channel is not connected. */
17
+ send: (msg: unknown) => boolean
18
+ reconnect: () => void
19
+ }
20
+
21
+ export interface ChatTransportActions {
22
+ send: (msg: unknown) => boolean
23
+ reconnect: () => void
24
+ getStatus: () => ChannelStatus
25
+ }
26
+
27
+ export interface ChatTransportHub {
28
+ transport: ChatTransport
29
+ deliverFrame: (raw: string) => void
30
+ announceReady: () => void
31
+ announceStatus: (status: ChannelStatus) => void
32
+ }
33
+
34
+ interface Listeners<T> {
35
+ add: (listener: (value: T) => void) => () => void
36
+ emit: (value: T) => void
37
+ }
38
+
39
+ function createListeners<T>(): Listeners<T> {
40
+ const listeners = new Set<(value: T) => void>()
41
+ return {
42
+ add: (listener) => {
43
+ listeners.add(listener)
44
+ return () => listeners.delete(listener)
45
+ },
46
+ emit: (value) => {
47
+ for (const listener of listeners) {
48
+ try {
49
+ listener(value)
50
+ } catch (error: unknown) {
51
+ console.error(`${LOG_PREFIX} chat listener failed`, error)
52
+ }
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ export function createChatTransport(
59
+ actions: ChatTransportActions,
60
+ ): ChatTransportHub {
61
+ const frames = createListeners<string>()
62
+ const ready = createListeners<undefined>()
63
+ const statuses = createListeners<ChannelStatus>()
64
+ return {
65
+ transport: {
66
+ onFrame: frames.add,
67
+ onReady: (listener) => ready.add(() => listener()),
68
+ onStatus: statuses.add,
69
+ getStatus: actions.getStatus,
70
+ send: actions.send,
71
+ reconnect: actions.reconnect,
72
+ },
73
+ deliverFrame: frames.emit,
74
+ announceReady: () => ready.emit(undefined),
75
+ announceStatus: statuses.emit,
76
+ }
77
+ }
78
+
79
+ /** Publishes the transport where the chat document looks for it. */
80
+ export function exposeChatTransport(transport: ChatTransport): void {
81
+ Reflect.set(globalThis, CHAT_TRANSPORT_KEY, transport)
82
+ }
@@ -3,17 +3,36 @@ export const OVERLAY_Z_INDEX = 999_999
3
3
  export const OVERLAY_DOM_ID = 'dms-ai-overlay-root'
4
4
  export const OVERLAY_IFRAME_ID = 'dms-ai-overlay-iframe'
5
5
  export const SIDECAR_INFO_PATH = '/ai/sidecar-info'
6
- export const SIDECAR_PROBE_HOST = 'http://localhost'
6
+ /** The chat document, served through the DMS from the dashboard's own origin. */
7
+ export const CHATBOX_PATH = '/api/ai/chatbox/'
8
+ /** Event stream to receive, POST to send: HTTP the DMS frontend server relays. */
9
+ export const CHANNELS_PATH = '/ai/channels'
10
+ export const CHANNEL_EVENTS_SEGMENT = 'events'
11
+ export const CHANNEL_MESSAGES_SEGMENT = 'messages'
12
+ export const CHANNEL_LIST_SEPARATOR = ','
13
+ export const HOST_CHANNEL = 'host'
14
+ export const CHAT_CHANNEL = 'chat'
15
+ export const CHANNEL_EVENT_READY = 'ready'
16
+ /**
17
+ * How long the stream outlives a closed panel or a hidden tab, so a quick
18
+ * close and reopen does not reconnect, while a tab left in the background soon
19
+ * gives back its one of the browser's six HTTP/1.1 connections to the dashboard.
20
+ */
21
+ export const CHANNEL_IDLE_STOP_MS = 2_000
22
+ export const VISIBILITY_CHANGE_EVENT = 'visibilitychange'
23
+ /** Where the chat document, same-origin, finds the dashboard's chat transport. */
24
+ export const CHAT_TRANSPORT_KEY = 'dmsAiChatTransport'
25
+ export const OVERLAY_OPEN_CHANGE_EVENT = 'dms-ai:open-change'
26
+ export const EVENT_STREAM_TYPE = 'text/event-stream'
27
+ export const JSON_CONTENT_TYPE = 'application/json'
7
28
  export const OVERLAY_BOX_SHADOW = '0 8px 24px rgba(0, 0, 0, 0.18)'
8
- export const SIDECAR_HOST_NAME = 'localhost'
9
- export const WS_HOST_PATH = '/ws/host'
10
- export const WS_RECONNECT_DELAYS_MS = [
29
+ export const CHANNEL_RECONNECT_DELAYS_MS = [
11
30
  1000, 2000, 4000, 8000, 15000, 30000,
12
31
  ] as const
13
32
  // Sidecar reachability, as seen by the host. `reviving` is the self-healing
14
- // state (idle-exited / crashed-respawning): the probe loop is relearning the new
15
- // random port and will repoint the frame. `unavailable` is terminal (the crash
16
- // budget is spent) and needs a reload.
33
+ // state (idle-exited / crashed-respawning): the probe loop revives the sidecar
34
+ // and the channels reconnect once it answers. `unavailable` is terminal (the
35
+ // crash budget is spent) and needs a reload.
17
36
  export const SIDECAR_STATUS_CONNECTING = 'connecting'
18
37
  export const SIDECAR_STATUS_CONNECTED = 'connected'
19
38
  export const SIDECAR_STATUS_REVIVING = 'reviving'
@@ -30,13 +49,10 @@ export const PLACEHOLDER_REVIVING_TEXT = 'Reconnecting the assistant…'
30
49
  export const PLACEHOLDER_UNAVAILABLE_TITLE = 'Assistant unavailable'
31
50
  export const PLACEHOLDER_UNAVAILABLE_TEXT = 'Reload the page to try again.'
32
51
 
33
- export const WS_STATUS_CONNECTING = 'connecting'
34
- export const WS_STATUS_CONNECTED = 'connected'
35
- export const WS_STATUS_DISCONNECTED = 'disconnected'
36
- export const WS_STATUS_RECONNECTING = 'reconnecting'
37
- export const WS_PROTOCOL_SECURE = 'wss:'
38
- export const WS_PROTOCOL_INSECURE = 'ws:'
39
- export const PAGE_PROTOCOL_SECURE = 'https:'
52
+ export const CHANNEL_STATUS_CONNECTING = 'connecting'
53
+ export const CHANNEL_STATUS_CONNECTED = 'connected'
54
+ export const CHANNEL_STATUS_DISCONNECTED = 'disconnected'
55
+ export const CHANNEL_STATUS_RECONNECTING = 'reconnecting'
40
56
  export const HOST_ROLE = 'host'
41
57
  export const HELLO_MESSAGE_TYPE = 'hello'
42
58
  export const LOG_PREFIX = '[dms-ai]'
@@ -52,7 +68,7 @@ export const TOGGLE_SHORTCUT_KEY = 'k'
52
68
  export const MODAL_OPEN_SELECTOR = '[role="dialog"][aria-modal="true"]'
53
69
 
54
70
  // Generic header-action registry exposed by the DMS core (dms-back). Any module
55
- // can push a button by writing to this shared Nuxt state key; the core renders
71
+ // can push a button by writing to this shared `useDmsState` key; the core renders
56
72
  // it with no knowledge of who registered it.
57
73
  export const HEADER_ACTIONS_STATE_KEY = 'dms:header-actions'
58
74
  export const LAUNCHER_ACTION_ID = 'dms-ai-launcher'
@@ -14,14 +14,18 @@ interface InstallCurrentPageTrackerOptions {
14
14
  send: (msg: HostStateUpdateMessage) => void
15
15
  }
16
16
 
17
+ /** The state update announcing the page at `url`. */
18
+ export function buildCurrentPageUpdate(url: string): HostStateUpdateMessage {
19
+ return {
20
+ type: HOST_STATE_UPDATE_TYPE,
21
+ currentPage: { path: new URL(url, window.location.origin).pathname },
22
+ }
23
+ }
24
+
17
25
  export function installCurrentPageTracker(
18
26
  options: InstallCurrentPageTrackerOptions,
19
27
  ): () => void {
20
- const pushFor = (url: string): void =>
21
- options.send({
22
- type: HOST_STATE_UPDATE_TYPE,
23
- currentPage: { path: new URL(url, window.location.origin).pathname },
24
- })
28
+ const pushFor = (url: string): void => options.send(buildCurrentPageUpdate(url))
25
29
  const stop = router.on('navigate', (event) => pushFor(event.detail.page.url))
26
30
  pushFor(window.location.href)
27
31
  return stop