@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
|
@@ -4,85 +4,131 @@ import {
|
|
|
4
4
|
useDmsRouter,
|
|
5
5
|
} from '#dms/frontend-module'
|
|
6
6
|
import {
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
WS_STATUS_CONNECTING,
|
|
27
|
+
VISIBILITY_CHANGE_EVENT,
|
|
15
28
|
} from '../runtime/constants'
|
|
16
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
buildCurrentPageUpdate,
|
|
31
|
+
installCurrentPageTracker,
|
|
32
|
+
} from '../runtime/current-page'
|
|
17
33
|
import { registerLauncherAction } from '../runtime/header-action'
|
|
18
|
-
import {
|
|
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
|
-
|
|
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 `${
|
|
62
|
+
return `${CHATBOX_PATH}?theme=${mode}`
|
|
36
63
|
}
|
|
37
64
|
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
if (status
|
|
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
|
-
|
|
127
|
+
return (status) => {
|
|
78
128
|
if (status === SIDECAR_STATUS_CONNECTED) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
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
|
-
|
|
141
|
+
}
|
|
96
142
|
|
|
143
|
+
function installHostState(channel: ChannelClient): () => void {
|
|
97
144
|
const sendWhenConnected = (msg: unknown): void => {
|
|
98
|
-
if (!
|
|
99
|
-
|
|
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
|
-
|
|
152
|
+
return () => {
|
|
106
153
|
stopTracking()
|
|
107
154
|
stopNavigation()
|
|
108
155
|
}
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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]'
|
|
@@ -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
|
|
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
|