@antelopejs/dms-ai 0.1.2 → 0.1.3
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/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 +8 -0
- 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 +30 -14
- 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,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
|
-
|
|
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
|
|
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
|
|
15
|
-
//
|
|
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
|
|
34
|
-
export const
|
|
35
|
-
export const
|
|
36
|
-
export const
|
|
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]'
|
|
@@ -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
|
|
@@ -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()
|