dsh-surface-bridge 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +310 -0
- package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
- package/lib/types/client/SurfaceSelectionDock.js +125 -0
- package/lib/types/client/index.d.ts +40 -0
- package/lib/types/client/index.js +41 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/service.d.ts +46 -0
- package/lib/types/client/service.js +89 -0
- package/lib/types/client/transport.d.ts +19 -0
- package/lib/types/client/transport.js +38 -0
- package/lib/types/contract.d.ts +329 -0
- package/lib/types/contract.js +38 -0
- package/lib/types/host/narrow.d.ts +25 -0
- package/lib/types/host/narrow.js +193 -0
- package/lib/types/host/render.d.ts +63 -0
- package/lib/types/host/render.js +228 -0
- package/lib/types/host/routes.d.ts +31 -0
- package/lib/types/host/routes.js +108 -0
- package/lib/types/host/service.d.ts +41 -0
- package/lib/types/host/service.js +93 -0
- package/lib/types/host/store.d.ts +85 -0
- package/lib/types/host/store.js +206 -0
- package/lib/types/index.d.ts +93 -0
- package/lib/types/index.js +132 -0
- package/package.json +88 -0
- package/src/client/SurfaceSelectionDock.module.css +186 -0
- package/src/client/SurfaceSelectionDock.tsx +245 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +42 -0
- package/src/client/service.ts +110 -0
- package/src/client/transport.ts +39 -0
- package/src/contract.ts +351 -0
- package/src/css-modules.d.ts +10 -0
- package/src/host/narrow.ts +180 -0
- package/src/host/render.ts +226 -0
- package/src/host/routes.ts +117 -0
- package/src/host/service.ts +116 -0
- package/src/host/store.ts +236 -0
- package/src/index.ts +194 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side state of the surface bridge: one pending selection and one operation
|
|
3
|
+
* queue per Session, plus the liveness stamp that lets a write-back tool fail
|
|
4
|
+
* loudly instead of pretending a closed surface accepted its edit.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is in-memory and process-local by design. A selection is a
|
|
7
|
+
* snapshot of what the user is looking at right now; persisting it across a
|
|
8
|
+
* restart would resurrect a context the user never chose in this process. The
|
|
9
|
+
* operation queue is a handoff channel between two halves of one running app,
|
|
10
|
+
* so it has no meaning once either half is gone.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-surface-bridge/host/store
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SurfaceOperation, SurfaceOperationResult, SurfaceSelection } from '../contract.ts'
|
|
16
|
+
import { READ_SELECTION_OP } from '../contract.ts'
|
|
17
|
+
|
|
18
|
+
/** How long a poll may hold before it answers empty, in milliseconds. */
|
|
19
|
+
export const DEFAULT_POLL_HOLD_MS = 20_000
|
|
20
|
+
/** How long after the last poll a surface is still considered open. */
|
|
21
|
+
export const SURFACE_LIVENESS_MS = 45_000
|
|
22
|
+
/** How long a write-back tool waits for the surface to report a result. */
|
|
23
|
+
export const DEFAULT_OPERATION_TIMEOUT_MS = 25_000
|
|
24
|
+
/**
|
|
25
|
+
* How long a model step waits for a surface to answer a read.
|
|
26
|
+
*
|
|
27
|
+
* Short on purpose: a live surface is already parked on a poll, so an answer is a
|
|
28
|
+
* round trip on an idle connection. If it does not come, the step proceeds without
|
|
29
|
+
* the selection rather than delaying the user's message.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_READ_TIMEOUT_MS = 600
|
|
32
|
+
|
|
33
|
+
/** Per-Session bridge state. */
|
|
34
|
+
interface SessionState {
|
|
35
|
+
/** Operations awaiting a poll. */
|
|
36
|
+
readonly queued: SurfaceOperation[]
|
|
37
|
+
/** Settled results by operation id. */
|
|
38
|
+
readonly settled: Map<string, SurfaceOperationResult>
|
|
39
|
+
/** Waiters woken when the queue or a settled result changes. */
|
|
40
|
+
readonly waiters: Set<() => void>
|
|
41
|
+
/** Host clock of the most recent poll; the surface's liveness stamp. */
|
|
42
|
+
lastPollAt: number
|
|
43
|
+
/** Monotonic operation counter for this Session. */
|
|
44
|
+
operationSeq: number
|
|
45
|
+
/** Set by {@link SurfaceBridgeStore.forget} so waiters stop holding a dead Session. */
|
|
46
|
+
released: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The bridge's Host state, keyed by Session id. */
|
|
50
|
+
export class SurfaceBridgeStore {
|
|
51
|
+
private readonly sessions = new Map<string, SessionState>()
|
|
52
|
+
|
|
53
|
+
/** Resolve one Session's state, creating it on first use. */
|
|
54
|
+
private stateOf(sessionId: string): SessionState {
|
|
55
|
+
const existing = this.sessions.get(sessionId)
|
|
56
|
+
if (existing !== undefined) return existing
|
|
57
|
+
const created: SessionState = {
|
|
58
|
+
queued: [],
|
|
59
|
+
settled: new Map(),
|
|
60
|
+
waiters: new Set(),
|
|
61
|
+
lastPollAt: 0,
|
|
62
|
+
operationSeq: 0,
|
|
63
|
+
released: false,
|
|
64
|
+
}
|
|
65
|
+
this.sessions.set(sessionId, created)
|
|
66
|
+
return created
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Wake every waiter of one Session. */
|
|
70
|
+
private wake(state: SessionState): void {
|
|
71
|
+
for (const waiter of [...state.waiters]) waiter()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether any surface of one Session has polled recently enough to be considered open. */
|
|
75
|
+
isSurfaceLive(sessionId: string, now = Date.now()): boolean {
|
|
76
|
+
const state = this.sessions.get(sessionId)
|
|
77
|
+
if (state === undefined) return false
|
|
78
|
+
return now - state.lastPollAt <= SURFACE_LIVENESS_MS
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Queue one operation for a surface and return it with its minted id. */
|
|
82
|
+
enqueue(sessionId: string, source: string, op: string, payload: unknown): SurfaceOperation {
|
|
83
|
+
const state = this.stateOf(sessionId)
|
|
84
|
+
state.operationSeq += 1
|
|
85
|
+
const operation: SurfaceOperation = {
|
|
86
|
+
id: `${sessionId}:${state.operationSeq}`,
|
|
87
|
+
source,
|
|
88
|
+
op,
|
|
89
|
+
payload,
|
|
90
|
+
}
|
|
91
|
+
state.queued.push(operation)
|
|
92
|
+
this.wake(state)
|
|
93
|
+
return operation
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ask a live surface what it has selected, and wait for its answer.
|
|
98
|
+
*
|
|
99
|
+
* Implemented on the operation queue rather than as its own channel: the browser
|
|
100
|
+
* already holds a poll open, so a read is one enqueue plus the answer the loop
|
|
101
|
+
* sends back. A surface that is closed answers nothing, and the read gives up
|
|
102
|
+
* rather than holding a model step open.
|
|
103
|
+
*
|
|
104
|
+
* @param sessionId - Session whose surfaces are asked.
|
|
105
|
+
* @param timeoutMs - Bound on the wait.
|
|
106
|
+
* @param signal - Aborts the wait when the turn is cancelled.
|
|
107
|
+
* @param consume - Whether the surface should spend its selection answering this.
|
|
108
|
+
* @returns the raw answer (an array of selections), or `undefined` when nothing answered.
|
|
109
|
+
*/
|
|
110
|
+
async readSelections(
|
|
111
|
+
sessionId: string,
|
|
112
|
+
timeoutMs: number,
|
|
113
|
+
signal?: AbortSignal,
|
|
114
|
+
consume = false,
|
|
115
|
+
): Promise<readonly unknown[] | undefined> {
|
|
116
|
+
// No answer can come if nothing is polling: the surface is closed, or its tab
|
|
117
|
+
// was never opened in this Session.
|
|
118
|
+
if (!this.isSurfaceLive(sessionId)) return undefined
|
|
119
|
+
const request = this.enqueue(sessionId, '*', READ_SELECTION_OP, { consume })
|
|
120
|
+
const result = await this.awaitResult(sessionId, request.id, timeoutMs, signal)
|
|
121
|
+
if (result === undefined || !result.ok) return undefined
|
|
122
|
+
return Array.isArray(result.value) ? result.value as readonly unknown[] : []
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Take the operations waiting for one Session, holding the request open until
|
|
127
|
+
* something arrives or the hold expires.
|
|
128
|
+
*
|
|
129
|
+
* A long poll rather than a socket: the surface already runs a fetch loop, and
|
|
130
|
+
* a held request is one line of client code with no reconnect protocol of its
|
|
131
|
+
* own. The hold is bounded so a broken connection cannot pin a handler forever.
|
|
132
|
+
*
|
|
133
|
+
* @param sessionId - Session whose surface is polling.
|
|
134
|
+
* @param holdMs - Maximum time to hold before answering empty.
|
|
135
|
+
* @param signal - Aborts the hold when the browser goes away.
|
|
136
|
+
* @returns the queued operations, oldest first.
|
|
137
|
+
*/
|
|
138
|
+
async poll(sessionId: string, holdMs: number, signal?: AbortSignal): Promise<readonly SurfaceOperation[]> {
|
|
139
|
+
const state = this.stateOf(sessionId)
|
|
140
|
+
state.lastPollAt = Date.now()
|
|
141
|
+
if (state.queued.length > 0) return state.queued.splice(0, state.queued.length)
|
|
142
|
+
if (holdMs <= 0 || signal?.aborted === true) return []
|
|
143
|
+
|
|
144
|
+
await new Promise<void>((resolve) => {
|
|
145
|
+
let settled = false
|
|
146
|
+
const finish = (): void => {
|
|
147
|
+
if (settled) return
|
|
148
|
+
settled = true
|
|
149
|
+
state.waiters.delete(onWake)
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
signal?.removeEventListener('abort', onWake)
|
|
152
|
+
resolve()
|
|
153
|
+
}
|
|
154
|
+
const onWake = (): void => { finish() }
|
|
155
|
+
const timer = setTimeout(finish, holdMs)
|
|
156
|
+
state.waiters.add(onWake)
|
|
157
|
+
signal?.addEventListener('abort', onWake, { once: true })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return state.queued.splice(0, state.queued.length)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Record a surface-reported result and wake whoever is waiting for it. */
|
|
164
|
+
settle(sessionId: string, result: SurfaceOperationResult): boolean {
|
|
165
|
+
const state = this.sessions.get(sessionId)
|
|
166
|
+
if (state === undefined) return false
|
|
167
|
+
state.settled.set(result.id, result)
|
|
168
|
+
this.wake(state)
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wait for one operation's result.
|
|
174
|
+
*
|
|
175
|
+
* @param sessionId - Session that owns the operation.
|
|
176
|
+
* @param operationId - Operation to await.
|
|
177
|
+
* @param timeoutMs - Bound on the wait; a timeout is a real failure the caller reports.
|
|
178
|
+
* @param signal - Aborts the wait when the Agent turn is cancelled.
|
|
179
|
+
* @returns the result, or `undefined` when the surface never reported.
|
|
180
|
+
*/
|
|
181
|
+
async awaitResult(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
operationId: string,
|
|
184
|
+
timeoutMs: number,
|
|
185
|
+
signal?: AbortSignal,
|
|
186
|
+
): Promise<SurfaceOperationResult | undefined> {
|
|
187
|
+
const state = this.stateOf(sessionId)
|
|
188
|
+
const immediate = state.settled.get(operationId)
|
|
189
|
+
if (immediate !== undefined) {
|
|
190
|
+
state.settled.delete(operationId)
|
|
191
|
+
return immediate
|
|
192
|
+
}
|
|
193
|
+
if (signal?.aborted === true) return undefined
|
|
194
|
+
|
|
195
|
+
return await new Promise<SurfaceOperationResult | undefined>((resolve) => {
|
|
196
|
+
let settled = false
|
|
197
|
+
const finish = (result: SurfaceOperationResult | undefined): void => {
|
|
198
|
+
if (settled) return
|
|
199
|
+
settled = true
|
|
200
|
+
state.waiters.delete(onWake)
|
|
201
|
+
clearTimeout(timer)
|
|
202
|
+
signal?.removeEventListener('abort', onWake)
|
|
203
|
+
resolve(result)
|
|
204
|
+
}
|
|
205
|
+
const onWake = (): void => {
|
|
206
|
+
if (signal?.aborted === true || state.released) {
|
|
207
|
+
finish(undefined)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const result = state.settled.get(operationId)
|
|
211
|
+
if (result === undefined) return
|
|
212
|
+
state.settled.delete(operationId)
|
|
213
|
+
finish(result)
|
|
214
|
+
}
|
|
215
|
+
const timer = setTimeout(() => { finish(undefined) }, timeoutMs)
|
|
216
|
+
state.waiters.add(onWake)
|
|
217
|
+
signal?.addEventListener('abort', onWake, { once: true })
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Forget one Session's whole bridge state (Session teardown). */
|
|
222
|
+
forget(sessionId: string): void {
|
|
223
|
+
const state = this.sessions.get(sessionId)
|
|
224
|
+
if (state === undefined) return
|
|
225
|
+
// Mark before waking: a waiter must see the release and stop waiting, not
|
|
226
|
+
// look up a result that will never arrive.
|
|
227
|
+
state.released = true
|
|
228
|
+
this.sessions.delete(sessionId)
|
|
229
|
+
this.wake(state)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Every Session currently holding bridge state; used by teardown sweeps. */
|
|
233
|
+
sessions_(): readonly string[] {
|
|
234
|
+
return [...this.sessions.keys()]
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node half of dsh-surface-bridge.
|
|
3
|
+
*
|
|
4
|
+
* The bridge exists so a right-Sidebar business surface can hand a selection to
|
|
5
|
+
* the composer once, and have it arrive in the model step as real context. This
|
|
6
|
+
* half owns the two things that must be unique per process: the Session-keyed
|
|
7
|
+
* selection/operation state, and the `agent/pre-step` listener that turns a read
|
|
8
|
+
* selection into **two** durable messages — one visible row the person can see (a
|
|
9
|
+
* file chip for the drawing, plus a one-line summary) and one hidden row carrying
|
|
10
|
+
* the element table the model works from.
|
|
11
|
+
*
|
|
12
|
+
* Why a mounted bundle rather than a shared library: a library would be inlined
|
|
13
|
+
* into each consuming plugin's own bundle, giving every consumer its own service
|
|
14
|
+
* instance and its own composer chip. The uniqueness the seam needs — one chip,
|
|
15
|
+
* one injection point — can only be guaranteed by the Loader mounting one bundle.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-surface-bridge
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-client-connection'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-attachment'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
24
|
+
import { createUserMessage, type ContentBlock, type ContextFormed } from '@deepseek-ai/dsh-llm'
|
|
25
|
+
import type {
|
|
26
|
+
SurfaceBridgeHostFace,
|
|
27
|
+
SurfaceBridgeService,
|
|
28
|
+
SurfaceSelection,
|
|
29
|
+
} from './contract.ts'
|
|
30
|
+
import { renderSelectionChip, renderSelectionText } from './host/render.ts'
|
|
31
|
+
import { registerSurfaceBridgeRoutes } from './host/routes.ts'
|
|
32
|
+
import { SurfaceBridgeHost } from './host/service.ts'
|
|
33
|
+
import { SurfaceBridgeStore } from './host/store.ts'
|
|
34
|
+
|
|
35
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
36
|
+
interface MessageSourceMap {
|
|
37
|
+
/**
|
|
38
|
+
* The hidden half of one surface selection: the element table the model reads.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately a producer kind, because that is what makes it *invisible*: the Chat
|
|
41
|
+
* view projects any source whose kind is not `user` into a `context` node, and renders
|
|
42
|
+
* no row for those. The visible half is a plain user message; see
|
|
43
|
+
* {@link buildSelectionMessages}.
|
|
44
|
+
*/
|
|
45
|
+
'surface-selection': {
|
|
46
|
+
kind: 'surface-selection'
|
|
47
|
+
} & ContextFormed
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The whole shared vocabulary, re-exported from the entry consumers already use.
|
|
53
|
+
*
|
|
54
|
+
* A surface plugin calls `ctx.surfaceBridge`/`ctx.surfaceBridgeHost` at runtime and
|
|
55
|
+
* never imports either half, but it must be able to *name* the faces and the
|
|
56
|
+
* descriptor it registers. Type-only, so neither half gains a runtime dependency
|
|
57
|
+
* on the other — and this module is also what carries the `Context` augmentation
|
|
58
|
+
* below, which is why a consumer should import the bridge by its package name
|
|
59
|
+
* rather than reaching for `./contract` alone.
|
|
60
|
+
*/
|
|
61
|
+
export type * from './contract.ts'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The two services this bundle provides.
|
|
65
|
+
*
|
|
66
|
+
* This block MUST stay in the package entry module. TypeScript merges an
|
|
67
|
+
* `interface Context` augmentation into the cordis class only when the augmenting
|
|
68
|
+
* file is reached as a program root through a non-empty import; placed in
|
|
69
|
+
* `contract.ts` (or any module that also carries the shared types) the same block
|
|
70
|
+
* degrades into an *ambient* declaration that REPLACES cordis's `Context`, and
|
|
71
|
+
* every `ctx.effect`/`ctx.on` in the graph stops type-checking. The build guard in
|
|
72
|
+
* `tests/context-augmentation.test.mjs` fails if it moves.
|
|
73
|
+
*/
|
|
74
|
+
declare module '@deepseek-ai/cordis' {
|
|
75
|
+
interface Context {
|
|
76
|
+
/** Browser face of the surface bridge; the seam a business surface publishes into. */
|
|
77
|
+
surfaceBridge: SurfaceBridgeService
|
|
78
|
+
/** Host face of the surface bridge; the seam a business plugin's tools call. */
|
|
79
|
+
surfaceBridgeHost: SurfaceBridgeHostFace
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
84
|
+
export const name = 'dsh-surface-bridge'
|
|
85
|
+
|
|
86
|
+
/** Route registration and image admission are the only Host capabilities this half needs. */
|
|
87
|
+
export const inject = ['connection', 'attachments'] as const
|
|
88
|
+
|
|
89
|
+
/** Decode a raster into the bytes the attachment service admits. */
|
|
90
|
+
function decodeRaster(data: string): Uint8Array {
|
|
91
|
+
return new Uint8Array(Buffer.from(data, 'base64'))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the two durable messages one selection travels in.
|
|
96
|
+
*
|
|
97
|
+
* **Two messages, one visible.** The person who sent a selection must be able to see what
|
|
98
|
+
* went out; the model needs the whole element table. Those are different appetites, and one
|
|
99
|
+
* message cannot satisfy both:
|
|
100
|
+
*
|
|
101
|
+
* · the **visible** row is a plain user message carrying one line — `画布选区 · main.excalidraw ·
|
|
102
|
+
* 3 个元素` — because the Chat view renders no row at all for a producer-tagged context node
|
|
103
|
+
* (`isVisibleChatNode` excludes ordinary Context, keeping only tool changes). Before this,
|
|
104
|
+
* the selection reached the model and appeared nowhere in the transcript.
|
|
105
|
+
* · the **detail** row is producer-tagged, and therefore hidden: it carries the element ids,
|
|
106
|
+
* coordinates and sizes the model edits from, plus the file path the write-back must name.
|
|
107
|
+
*
|
|
108
|
+
* Images ride in the visible row, so a person sees the pictures they sent. Bytes never enter
|
|
109
|
+
* the text, and a surface with no image element pays for no image.
|
|
110
|
+
*
|
|
111
|
+
* @param ctx - Plugin context carrying the attachment service.
|
|
112
|
+
* @param selection - Selection to render.
|
|
113
|
+
* @returns the messages to append to the step, visible row first.
|
|
114
|
+
*/
|
|
115
|
+
export async function buildSelectionMessages(ctx: Context, selection: SurfaceSelection): Promise<readonly ReturnType<typeof createUserMessage>[]> {
|
|
116
|
+
const reference = `${selection.source}#${selection.revision}`
|
|
117
|
+
const detail = createUserMessage({
|
|
118
|
+
content: [{ type: 'text', text: renderSelectionText(selection, reference) }],
|
|
119
|
+
// A producer kind, on purpose: this is the row the transcript hides.
|
|
120
|
+
source: { kind: 'surface-selection' },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// One line, and nothing above it. An earlier version put a `file` block first, which the
|
|
124
|
+
// shell renders as a 240x64 card — two stacked blocks for one selection, and the card was a
|
|
125
|
+
// *snapshot* of the drawing that the model had to be told not to edit. The line alone says
|
|
126
|
+
// which drawing and how many elements, which is what the reader needs.
|
|
127
|
+
const visible: ContentBlock[] = []
|
|
128
|
+
const images = selection.images ?? []
|
|
129
|
+
if (images.length > 0) {
|
|
130
|
+
const refs = await ctx.attachments.saveImages(images.map((image, index) => ({
|
|
131
|
+
data: decodeRaster(image.data),
|
|
132
|
+
mediaType: image.mediaType,
|
|
133
|
+
name: image.name ?? `${selection.source}-${image.elementId ?? `image-${String(index + 1)}`}.png`,
|
|
134
|
+
})))
|
|
135
|
+
for (const ref of refs) visible.push({ type: 'image', attachment: ref })
|
|
136
|
+
}
|
|
137
|
+
visible.push({ type: 'text', text: renderSelectionChip(selection) })
|
|
138
|
+
|
|
139
|
+
const shown = createUserMessage({
|
|
140
|
+
content: visible,
|
|
141
|
+
// `user`, so the transcript shows it. Any other kind makes the row a `context` node,
|
|
142
|
+
// and the Chat view renders no row for those.
|
|
143
|
+
source: { kind: 'user' },
|
|
144
|
+
})
|
|
145
|
+
return [shown, detail]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Plugin body: provide the Host face, serve the bridge routes, and arm the
|
|
150
|
+
* pre-step injection.
|
|
151
|
+
*
|
|
152
|
+
* @param ctx - Registrant context; every registration is disposed with it.
|
|
153
|
+
*/
|
|
154
|
+
/**
|
|
155
|
+
* Last turn that read each Session's surface.
|
|
156
|
+
*
|
|
157
|
+
* A turn has several steps (a model call, then the tool results, then another). The
|
|
158
|
+
* selection is a property of the message that opened the turn, so it is read once
|
|
159
|
+
* per turn; without this the context would be re-injected on every step and the
|
|
160
|
+
* model would see the same drawing three times.
|
|
161
|
+
*/
|
|
162
|
+
const lastReadTurn = new Map<string, number>()
|
|
163
|
+
|
|
164
|
+
export function apply(ctx: Context): void {
|
|
165
|
+
const store = new SurfaceBridgeStore()
|
|
166
|
+
const host = new SurfaceBridgeHost(store)
|
|
167
|
+
ctx.effect(() => ctx.reflect.provide('surfaceBridgeHost', host), 'dsh-surface-bridge: host face')
|
|
168
|
+
registerSurfaceBridgeRoutes(ctx, store)
|
|
169
|
+
|
|
170
|
+
ctx.on('agent/pre-step', async ({ agent, signal, turn, step }, next) => {
|
|
171
|
+
const decision = await next()
|
|
172
|
+
if (decision.kind === 'reject' || signal.aborted) return decision
|
|
173
|
+
// A selection belongs to the message that opened the turn, and that message is
|
|
174
|
+
// admitted at step 1. Gating on `step` rather than on the admitted message batch
|
|
175
|
+
// is deliberate: an empty batch at step 1 is a real case (a turn opened by
|
|
176
|
+
// something other than a typed prompt), and the batch is not a reliable signal
|
|
177
|
+
// for "the user just sent this". The whole point is also that the selection is
|
|
178
|
+
// read *here* — when the message is going out — not pushed while the user drew.
|
|
179
|
+
if (step !== 1) return decision
|
|
180
|
+
const sessionId = String(agent.id)
|
|
181
|
+
if (lastReadTurn.get(sessionId) === turn) return decision
|
|
182
|
+
// Consuming: this read exists to carry the selection into the message, so the
|
|
183
|
+
// surface drops it as it answers and the chip leaves the composer. A peek would
|
|
184
|
+
// leave it on screen and let the same drawing ride the next message too.
|
|
185
|
+
const selections = await host.consumeSelections(sessionId, signal)
|
|
186
|
+
if (selections === undefined || selections.length === 0) return decision
|
|
187
|
+
lastReadTurn.set(sessionId, turn)
|
|
188
|
+
const messages = [...decision.messages]
|
|
189
|
+
for (const selection of selections) {
|
|
190
|
+
messages.push(...await buildSelectionMessages(ctx, selection))
|
|
191
|
+
}
|
|
192
|
+
return { ...decision, messages }
|
|
193
|
+
}, { prepend: true })
|
|
194
|
+
}
|