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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/cordis.patch.yml +9 -0
  4. package/lib/client.js +310 -0
  5. package/lib/types/client/SurfaceSelectionDock.d.ts +37 -0
  6. package/lib/types/client/SurfaceSelectionDock.js +125 -0
  7. package/lib/types/client/index.d.ts +40 -0
  8. package/lib/types/client/index.js +41 -0
  9. package/lib/types/client/locales.d.ts +30 -0
  10. package/lib/types/client/locales.js +36 -0
  11. package/lib/types/client/service.d.ts +46 -0
  12. package/lib/types/client/service.js +89 -0
  13. package/lib/types/client/transport.d.ts +19 -0
  14. package/lib/types/client/transport.js +38 -0
  15. package/lib/types/contract.d.ts +329 -0
  16. package/lib/types/contract.js +38 -0
  17. package/lib/types/host/narrow.d.ts +25 -0
  18. package/lib/types/host/narrow.js +193 -0
  19. package/lib/types/host/render.d.ts +63 -0
  20. package/lib/types/host/render.js +228 -0
  21. package/lib/types/host/routes.d.ts +31 -0
  22. package/lib/types/host/routes.js +108 -0
  23. package/lib/types/host/service.d.ts +41 -0
  24. package/lib/types/host/service.js +93 -0
  25. package/lib/types/host/store.d.ts +85 -0
  26. package/lib/types/host/store.js +206 -0
  27. package/lib/types/index.d.ts +93 -0
  28. package/lib/types/index.js +132 -0
  29. package/package.json +88 -0
  30. package/src/client/SurfaceSelectionDock.module.css +186 -0
  31. package/src/client/SurfaceSelectionDock.tsx +245 -0
  32. package/src/client/index.ts +65 -0
  33. package/src/client/locales.ts +42 -0
  34. package/src/client/service.ts +110 -0
  35. package/src/client/transport.ts +39 -0
  36. package/src/contract.ts +351 -0
  37. package/src/css-modules.d.ts +10 -0
  38. package/src/host/narrow.ts +180 -0
  39. package/src/host/render.ts +226 -0
  40. package/src/host/routes.ts +117 -0
  41. package/src/host/service.ts +116 -0
  42. package/src/host/store.ts +236 -0
  43. package/src/index.ts +194 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Host-side validation of a selection that came back from a browser.
3
+ *
4
+ * The surface is the only producer of a selection, but it is still a browser
5
+ * boundary: a read answer is narrowed here before anything reads it, so a broken
6
+ * or hostile page cannot hand the renderer a shape it does not expect. Nothing is
7
+ * stored — this runs on the answer to a read, once per model step.
8
+ *
9
+ * @module dsh-surface-bridge/host/narrow
10
+ */
11
+
12
+ import type { SurfaceElement, SurfaceRaster, SurfaceResource, SurfaceSelection } from '../contract.ts'
13
+
14
+ /** Cap on the selection raster the browser may attach, in base64 characters (~450 KiB of PNG). */
15
+ export const MAX_RASTER_BASE64 = 600_000
16
+ /** Cap on elements accepted per selection; the source should already cap lower. */
17
+ export const MAX_ELEMENTS = 200
18
+ /** Cap on attached images per selection. */
19
+ export const MAX_IMAGES = 8
20
+
21
+ /** Narrow a finite number, or `undefined`. */
22
+ function finiteOrUndefined(value: unknown): number | undefined {
23
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
24
+ }
25
+
26
+ /** Narrow a style bag to short string/number entries. */
27
+ function narrowStyle(value: unknown): Readonly<Record<string, string | number>> | undefined {
28
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
29
+ const out: Record<string, string | number> = {}
30
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
31
+ if (typeof entry === 'string' || typeof entry === 'number') out[key] = entry
32
+ }
33
+ return Object.keys(out).length === 0 ? undefined : out
34
+ }
35
+
36
+ /** Narrow an asset locator bag. */
37
+ function narrowAsset(value: unknown): Readonly<Record<string, string>> | undefined {
38
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
39
+ const out: Record<string, string> = {}
40
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
41
+ if (typeof entry === 'string') out[key] = entry
42
+ }
43
+ return Object.keys(out).length === 0 ? undefined : out
44
+ }
45
+
46
+ /** Narrow a string array. */
47
+ function narrowStringArray(value: unknown): readonly string[] | undefined {
48
+ if (!Array.isArray(value)) return undefined
49
+ const out = value.filter((entry): entry is string => typeof entry === 'string')
50
+ return out.length === 0 ? undefined : out
51
+ }
52
+
53
+ /** Narrow one element projection; `undefined` rejects the whole selection. */
54
+ function narrowElement(value: unknown): SurfaceElement | undefined {
55
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
56
+ const raw = value as Record<string, unknown>
57
+ const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : undefined
58
+ const type = typeof raw.type === 'string' && raw.type.length > 0 ? raw.type : undefined
59
+ if (id === undefined || type === undefined) return undefined
60
+ const x = finiteOrUndefined(raw.x)
61
+ const y = finiteOrUndefined(raw.y)
62
+ const width = finiteOrUndefined(raw.width)
63
+ const height = finiteOrUndefined(raw.height)
64
+ if (x === undefined || y === undefined || width === undefined || height === undefined) return undefined
65
+ const linksRaw = raw.links
66
+ let links: SurfaceElement['links']
67
+ if (typeof linksRaw === 'object' && linksRaw !== null && !Array.isArray(linksRaw)) {
68
+ const bag = linksRaw as Record<string, unknown>
69
+ links = {
70
+ ...(typeof bag.from === 'string' ? { from: bag.from } : {}),
71
+ ...(typeof bag.to === 'string' ? { to: bag.to } : {}),
72
+ ...(typeof bag.container === 'string' ? { container: bag.container } : {}),
73
+ ...(narrowStringArray(bag.bound) === undefined ? {} : { bound: narrowStringArray(bag.bound) as readonly string[] }),
74
+ }
75
+ if (Object.keys(links).length === 0) links = undefined
76
+ }
77
+ return {
78
+ id,
79
+ type,
80
+ label: typeof raw.label === 'string' ? raw.label : type,
81
+ ...(typeof raw.text === 'string' ? { text: raw.text } : {}),
82
+ x,
83
+ y,
84
+ width,
85
+ height,
86
+ ...(finiteOrUndefined(raw.angle) === undefined ? {} : { angle: finiteOrUndefined(raw.angle) as number }),
87
+ ...(narrowStyle(raw.style) === undefined ? {} : { style: narrowStyle(raw.style) as Readonly<Record<string, string | number>> }),
88
+ ...(narrowAsset(raw.asset) === undefined ? {} : { asset: narrowAsset(raw.asset) as Readonly<Record<string, string>> }),
89
+ ...(links === undefined ? {} : { links }),
90
+ }
91
+ }
92
+
93
+ /** Narrow one raster; an oversized or malformed raster is dropped, never the selection. */
94
+ function narrowRaster(value: unknown): SurfaceRaster | undefined {
95
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
96
+ const raw = value as Record<string, unknown>
97
+ if (raw.mediaType !== 'image/png' && raw.mediaType !== 'image/jpeg' && raw.mediaType !== 'image/webp' && raw.mediaType !== 'image/gif') return undefined
98
+ if (typeof raw.data !== 'string' || raw.data.length === 0) return undefined
99
+ if (raw.data.length > MAX_RASTER_BASE64) return undefined
100
+ return {
101
+ mediaType: 'image/png',
102
+ data: raw.data,
103
+ ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
104
+ ...(typeof raw.elementId === 'string' ? { elementId: raw.elementId } : {}),
105
+ }
106
+ }
107
+
108
+ /** Narrow the attached image list, keeping only well-formed members. */
109
+ function narrowImages(value: unknown): readonly SurfaceRaster[] | undefined {
110
+ if (!Array.isArray(value)) return undefined
111
+ const out: SurfaceRaster[] = []
112
+ for (const entry of value.slice(0, MAX_IMAGES)) {
113
+ const image = narrowRaster(entry)
114
+ if (image !== undefined) out.push(image)
115
+ }
116
+ return out.length === 0 ? undefined : out
117
+ }
118
+
119
+ /** Narrow the document a selection came from; a malformed locator is dropped, not the selection. */
120
+ function narrowResource(value: unknown): SurfaceResource | undefined {
121
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
122
+ const raw = value as Record<string, unknown>
123
+ const resource: { path?: string; name?: string; version?: number } = {}
124
+ if (typeof raw.path === 'string' && raw.path.length > 0) resource.path = raw.path
125
+ if (typeof raw.name === 'string' && raw.name.length > 0) resource.name = raw.name
126
+ const version = finiteOrUndefined(raw.version)
127
+ if (version !== undefined) resource.version = version
128
+ return Object.keys(resource).length === 0 ? undefined : resource
129
+ }
130
+
131
+ /** Narrow one published selection, or explain what is wrong with it. */
132
+ export function narrowSelection(value: unknown): { ok: true; selection: SurfaceSelection } | { ok: false; error: string } {
133
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return { ok: false, error: 'selection 必须是对象' }
134
+ const raw = value as Record<string, unknown>
135
+ const source = typeof raw.source === 'string' && raw.source.length > 0 ? raw.source : undefined
136
+ if (source === undefined) return { ok: false, error: 'selection.source 必填' }
137
+ const revision = finiteOrUndefined(raw.revision)
138
+ if (revision === undefined || !Number.isSafeInteger(revision) || revision < 0) {
139
+ return { ok: false, error: 'selection.revision 必须是非负安全整数' }
140
+ }
141
+ const count = finiteOrUndefined(raw.count)
142
+ if (count === undefined || !Number.isSafeInteger(count) || count < 0) {
143
+ return { ok: false, error: 'selection.count 必须是非负安全整数' }
144
+ }
145
+ if (!Array.isArray(raw.elements)) return { ok: false, error: 'selection.elements 必须是数组' }
146
+ if (raw.elements.length > MAX_ELEMENTS) return { ok: false, error: `selection.elements 超过上限 ${MAX_ELEMENTS}` }
147
+ const elements: SurfaceElement[] = []
148
+ for (const entry of raw.elements) {
149
+ const element = narrowElement(entry)
150
+ if (element === undefined) return { ok: false, error: 'selection.elements 存在缺字段的元素' }
151
+ elements.push(element)
152
+ }
153
+ const boundsRaw = raw.bounds
154
+ let bounds: SurfaceSelection['bounds']
155
+ if (typeof boundsRaw === 'object' && boundsRaw !== null && !Array.isArray(boundsRaw)) {
156
+ const bag = boundsRaw as Record<string, unknown>
157
+ const x = finiteOrUndefined(bag.x)
158
+ const y = finiteOrUndefined(bag.y)
159
+ const width = finiteOrUndefined(bag.width)
160
+ const height = finiteOrUndefined(bag.height)
161
+ if (x !== undefined && y !== undefined && width !== undefined && height !== undefined) bounds = { x, y, width, height }
162
+ }
163
+ return {
164
+ ok: true,
165
+ selection: {
166
+ source,
167
+ revision,
168
+ count,
169
+ title: typeof raw.title === 'string' && raw.title.length > 0 ? raw.title : source,
170
+ summary: typeof raw.summary === 'string' ? raw.summary : `${count} 个元素`,
171
+ elements,
172
+ ...(raw.truncated === true ? { truncated: true } : {}),
173
+ ...(narrowResource(raw.resource) === undefined ? {} : { resource: narrowResource(raw.resource) as SurfaceResource }),
174
+ ...(bounds === undefined ? {} : { bounds }),
175
+ ...(narrowImages(raw.images) === undefined ? {} : { images: narrowImages(raw.images) as readonly SurfaceRaster[] }),
176
+ ...(narrowStringArray(raw.notes) === undefined ? {} : { notes: narrowStringArray(raw.notes) as readonly string[] }),
177
+ ...(narrowStringArray(raw.capabilities) === undefined ? {} : { capabilities: narrowStringArray(raw.capabilities) as readonly string[] }),
178
+ },
179
+ }
180
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Model-facing rendering of one selection.
3
+ *
4
+ * The text is the whole point of the bridge, so it is written for a reader that
5
+ * has never seen the surface: a one-line header naming the surface and the
6
+ * selection, the field semantics it needs to interpret the rows, one row per
7
+ * element, then the selection bounds and the operations it may issue.
8
+ *
9
+ * Token discipline is deliberate. Rows carry only facts a model can act on —
10
+ * identity, kind, text, geometry, non-default style, relations — and every value
11
+ * is rounded, because `x=120.00000000000001` costs the same as `x=120` and tells
12
+ * the model nothing. Heavy payloads never appear here: an image element reports
13
+ * its asset handle and its bytes travel beside this text as a real image block.
14
+ *
15
+ * @module dsh-surface-bridge/host/render
16
+ */
17
+
18
+ import type { SurfaceElement, SurfaceSelection } from '../contract.ts'
19
+
20
+ /** Maximum element rows rendered before the text degrades to a count. */
21
+ export const MAX_RENDERED_ELEMENTS = 40
22
+
23
+ /** Round a coordinate to one decimal; the precision a diagram actually needs. */
24
+ function round(value: number): number {
25
+ if (!Number.isFinite(value)) return 0
26
+ return Math.round(value * 10) / 10
27
+ }
28
+
29
+ /** Render one style bag as `key=value` pairs, sorted for a stable prompt. */
30
+ function renderStyle(style: Readonly<Record<string, string | number>> | undefined): string {
31
+ if (style === undefined) return ''
32
+ const entries = Object.entries(style)
33
+ if (entries.length === 0) return ''
34
+ const rendered = entries
35
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
36
+ .map(([key, value]) => `${key}=${typeof value === 'number' ? round(value) : value}`)
37
+ return ` style{${rendered.join(',')}}`
38
+ }
39
+
40
+ /** Render one asset locator bag, sorted for a stable prompt. */
41
+ function renderAsset(asset: Readonly<Record<string, string>> | undefined): string {
42
+ if (asset === undefined) return ''
43
+ const entries = Object.entries(asset)
44
+ if (entries.length === 0) return ''
45
+ const rendered = entries
46
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
47
+ .map(([key, value]) => `${key}=${value}`)
48
+ return ` asset{${rendered.join(',')}}`
49
+ }
50
+
51
+ /** Render one element's connection relations. */
52
+ function renderLinks(element: SurfaceElement): string {
53
+ const links = element.links
54
+ if (links === undefined) return ''
55
+ const parts: string[] = []
56
+ if (links.from !== undefined) parts.push(`from=${links.from}`)
57
+ if (links.to !== undefined) parts.push(`to=${links.to}`)
58
+ if (links.container !== undefined) parts.push(`container=${links.container}`)
59
+ if (links.bound !== undefined && links.bound.length > 0) parts.push(`bound=${links.bound.join('|')}`)
60
+ if (parts.length === 0) return ''
61
+ return ` links{${parts.join(',')}}`
62
+ }
63
+
64
+ /** Render one element row. */
65
+ export function renderElementRow(element: SurfaceElement): string {
66
+ const text = element.text === undefined || element.text.length === 0
67
+ ? ''
68
+ : ` text=${JSON.stringify(element.text)}`
69
+ const angle = element.angle === undefined || round(element.angle) === 0 ? '' : ` angle=${round(element.angle)}`
70
+ return `- ${element.type} id=${element.id}${text} @(${round(element.x)},${round(element.y)}) `
71
+ + `${round(element.width)}x${round(element.height)}${angle}`
72
+ + `${renderStyle(element.style)}${renderAsset(element.asset)}${renderLinks(element)}`
73
+ }
74
+
75
+ /** How many element names the visible summary line carries before it degrades to `等`. */
76
+ export const MAX_CHIP_NAMES = 3
77
+
78
+ /** How long one element name may be on the visible summary line. */
79
+ const MAX_CHIP_NAME_CHARS = 18
80
+
81
+ /**
82
+ * Width budget for the visible line, in half-width units.
83
+ *
84
+ * A user bubble is about 493px of usable width at the shell's 14px content font (max-width is
85
+ * `min(748px * .702, 82%)` minus 16px of padding each side), and a CJK glyph there is roughly
86
+ * 7px per unit of this measure — so ~70 units fit. The budget is set below that on purpose:
87
+ * the estimate is a proxy, and the failure mode it buys insurance against is the line wrapping
88
+ * to a second row, which is exactly what the request was about.
89
+ */
90
+ export const MAX_CHIP_LINE_UNITS = 56
91
+
92
+ /** Approximate width of one string in half-width units: CJK counts double. */
93
+ function widthUnits(text: string): number {
94
+ let total = 0
95
+ for (const char of text) total += (char.codePointAt(0) ?? 0) > 0x2e80 ? 2 : 1
96
+ return total
97
+ }
98
+
99
+ /** Width budget for the document name on the visible line, in half-width units. */
100
+ export const MAX_CHIP_FILE_UNITS = 24
101
+
102
+ /**
103
+ * Shorten a document name for the visible line, keeping its extension.
104
+ *
105
+ * A 40-character file name would fill the line on its own and push the count onto a second row
106
+ * — and the count is the part the reader is checking. The extension stays visible because it is
107
+ * the part that says what kind of thing this is; the full name is one row down, in the detail.
108
+ *
109
+ * @param name - Document name.
110
+ * @returns the name, or a shortened form ending in `…<extension>`.
111
+ */
112
+ function shortFileName(name: string): string {
113
+ if (widthUnits(name) <= MAX_CHIP_FILE_UNITS) return name
114
+ const dot = name.lastIndexOf('.')
115
+ const extension = dot > 0 ? name.slice(dot) : ''
116
+ const stem = dot > 0 ? name.slice(0, dot) : name
117
+ const room = MAX_CHIP_FILE_UNITS - widthUnits(extension) - 1
118
+ if (room <= 0) return `${name.slice(0, MAX_CHIP_FILE_UNITS - 1)}…`
119
+ let kept = ''
120
+ for (const char of stem) {
121
+ if (widthUnits(kept + char) > room) break
122
+ kept += char
123
+ }
124
+ // A separator left dangling before the ellipsis reads as a typo: `a-very-long-…` rather than
125
+ // `a-very-long…`.
126
+ kept = kept.replace(/[-_ .]+$/, '')
127
+ return `${kept}…${extension}`
128
+ }
129
+
130
+ /**
131
+ * Render the one line the person reads in the transcript.
132
+ *
133
+ * The element table stays in the hidden detail row, because a bubble of coordinates is not
134
+ * something a person wants in their conversation. What they do want is to recognise what they
135
+ * sent: the document, how many elements, and — when they fit — the first few names.
136
+ *
137
+ * **One line, by construction.** The line carries the document and the count, then as many
138
+ * names as the width budget allows; a name that would push it onto a second row is dropped
139
+ * rather than wrapped. A selection of unlabelled shapes (the common case for a drawing someone
140
+ * just made) therefore reads exactly `画布选区 · main.excalidraw · 1 个元素`.
141
+ *
142
+ * @param selection - Selection to summarise.
143
+ * @returns one line, e.g. `画布选区 · main.excalidraw · 2 个元素:下单、风控`.
144
+ */
145
+ export function renderSelectionChip(selection: SurfaceSelection): string {
146
+ const parts = [`${selection.title}选区`]
147
+ if (selection.resource?.name !== undefined) parts.push(shortFileName(selection.resource.name))
148
+ parts.push(`${selection.count} 个元素`)
149
+ const head = parts.join(' · ')
150
+ const names = (selection.elements ?? [])
151
+ .map(element => element.text ?? element.label)
152
+ .filter((name): name is string => typeof name === 'string' && name.length > 0)
153
+ .slice(0, MAX_CHIP_NAMES)
154
+ .map(name => (name.length > MAX_CHIP_NAME_CHARS ? `${name.slice(0, MAX_CHIP_NAME_CHARS)}…` : name))
155
+ if (names.length === 0) return head
156
+
157
+ // Greedy fit: add names while the whole line stays inside the budget. `等` is charged up
158
+ // front when there are more names than the list holds, so the suffix never overflows either.
159
+ const room = MAX_CHIP_LINE_UNITS - widthUnits(head) - 2
160
+ const fits: string[] = []
161
+ let used = 0
162
+ for (const name of names) {
163
+ const cost = widthUnits(name) + (fits.length === 0 ? 0 : 2)
164
+ if (used + cost > room) break
165
+ fits.push(name)
166
+ used += cost
167
+ }
168
+ if (fits.length === 0) return head
169
+ const suffix = selection.count > fits.length ? ' 等' : ''
170
+ return `${head}:${fits.join('、')}${suffix}`
171
+ }
172
+
173
+ /**
174
+ * Render one selection as the text that enters the model step.
175
+ *
176
+ * This is the hidden detail row: every actionable fact, and nothing written for a person to
177
+ * read — the visible line is {@link renderSelectionChip}. The two are deliberately different
178
+ * texts rather than one text shown twice.
179
+ *
180
+ * @param selection - Selection to render.
181
+ * @param selectionRef - Short stable handle for this selection, echoed by the tools.
182
+ * @returns the model-facing context text.
183
+ */
184
+ export function renderSelectionText(selection: SurfaceSelection, selectionRef: string): string {
185
+ const shown = selection.elements.slice(0, MAX_RENDERED_ELEMENTS)
186
+ const lines: string[] = []
187
+ lines.push(
188
+ `[${selection.title} 选择集 ${selectionRef}] ${selection.summary}`
189
+ + `(共 ${selection.count} 个元素${selection.truncated === true || shown.length < selection.count ? `,下列只列出前 ${shown.length} 个` : ''})`,
190
+ )
191
+ lines.push('元素字段:id 为稳定标识(写回时用它定位);x/y 是左上角坐标,width/height 是尺寸;style 只列出非默认值;links 描述箭头端点与容器绑定。')
192
+ if (selection.resource !== undefined) {
193
+ const resource = selection.resource
194
+ const where = resource.path ?? resource.name ?? ''
195
+ const when = resource.version === undefined ? '' : `(版本 ${String(resource.version)},也是文件最后修改时间)`
196
+ // Stated as the address to write back to, because that is the mistake this line
197
+ // prevents: a model that edits a same-named file somewhere else has not edited this.
198
+ if (where.length > 0) {
199
+ lines.push(`这份选择来自文件:${where}${when}。改动请指向这个文件,不要用同名文件代替。`)
200
+ }
201
+ }
202
+ if (shown.length === 0) {
203
+ lines.push('- (无元素明细)')
204
+ } else {
205
+ for (const element of shown) lines.push(renderElementRow(element))
206
+ }
207
+ if (selection.bounds !== undefined) {
208
+ const bounds = selection.bounds
209
+ lines.push(`选择集包围盒:@(${round(bounds.x)},${round(bounds.y)}) ${round(bounds.width)}x${round(bounds.height)}`)
210
+ }
211
+ const imageElements = selection.elements.filter(element => element.asset !== undefined)
212
+ if (imageElements.length > 0) {
213
+ lines.push(
214
+ `其中 ${imageElements.length} 个元素带图片素材:字节以图片块附在这次选区的可见消息里,文本里只保留 asset 定位,不要向用户索要图片数据。`,
215
+ )
216
+ }
217
+ if (selection.images !== undefined && selection.images.length > 0) {
218
+ const described = selection.images
219
+ .map(image => image.elementId === undefined ? '(整体)' : image.elementId)
220
+ .join('、')
221
+ lines.push(`这次选区附带 ${selection.images.length} 张图片(在可见消息里),对应元素:${described}。图片字节不在文本中,请直接使用这些图片。`)
222
+ }
223
+ for (const note of selection.notes ?? []) lines.push(note)
224
+ for (const capability of selection.capabilities ?? []) lines.push(capability)
225
+ return lines.join('\n')
226
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The bridge's Host HTTP surface.
3
+ *
4
+ * Two exact routes under `/api/data-canvas/`, registered on DSH's shared API
5
+ * channel so they inherit the platform's trust and authentication fence instead of
6
+ * opening a second server. Both serve one long poll: the surface asks for work and
7
+ * reports what happened. Nothing is pushed to the Host on a selection change, so
8
+ * there is no ingest route to validate — see `narrow.ts` for the validation that
9
+ * still guards a read answer.
10
+ *
11
+ * @module dsh-surface-bridge/host/routes
12
+ */
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import type { ConnectionFetchRoute } from '@deepseek-ai/dsh-client-connection'
16
+ import type {
17
+ BridgeErrorBody,
18
+ OperationSettleResponse,
19
+ OperationsPollResponse,
20
+ SurfaceOperationResult,
21
+ } from '../contract.ts'
22
+ import { OPS_PATH, SETTLE_PATH } from '../contract.ts'
23
+ import type { SurfaceBridgeStore } from './store.ts'
24
+ import { DEFAULT_POLL_HOLD_MS } from './store.ts'
25
+
26
+ export { OPS_PATH, SETTLE_PATH }
27
+
28
+ /** Build a JSON failure body. */
29
+ function fail(status: number, error: string): Response {
30
+ const body: BridgeErrorBody = { ok: false, error }
31
+ return Response.json(body, { status })
32
+ }
33
+
34
+ /** Read and parse a JSON body, answering the failure response the route should return. */
35
+ async function readJson(request: Request): Promise<{ ok: true; value: unknown } | { ok: false; response: Response }> {
36
+ const contentType = request.headers.get('content-type') ?? ''
37
+ if (!contentType.toLowerCase().startsWith('application/json')) {
38
+ return { ok: false, response: fail(415, '请求必须使用 application/json') }
39
+ }
40
+ try {
41
+ return { ok: true, value: await request.json() }
42
+ } catch {
43
+ return { ok: false, response: fail(400, '请求体不是合法 JSON') }
44
+ }
45
+ }
46
+
47
+ /** Narrow one operation result reported by a surface. */
48
+ function narrowResult(value: unknown): SurfaceOperationResult | undefined {
49
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
50
+ const raw = value as Record<string, unknown>
51
+ if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined
52
+ if (typeof raw.ok !== 'boolean') return undefined
53
+ return {
54
+ id: raw.id,
55
+ ok: raw.ok,
56
+ ...(typeof raw.detail === 'string' ? { detail: raw.detail } : {}),
57
+ ...(typeof raw.error === 'string' ? { error: raw.error } : {}),
58
+ // A read answer rides here; the service validates it before anyone reads it.
59
+ ...(raw.value === undefined ? {} : { value: raw.value }),
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build every route the bridge serves.
65
+ *
66
+ * @param store - The bridge's Session-keyed state.
67
+ * @returns the exact Fetch routes to register on DSH's shared API channel.
68
+ */
69
+ export function surfaceBridgeRoutes(store: SurfaceBridgeStore): readonly ConnectionFetchRoute[] {
70
+ return [
71
+ {
72
+ path: OPS_PATH,
73
+ methods: ['GET'],
74
+ requestBody: 'buffered',
75
+ fetch: async (request: Request): Promise<Response> => {
76
+ const url = new URL(request.url)
77
+ const sessionId = url.searchParams.get('sessionId')
78
+ if (sessionId === null || sessionId.length === 0) return fail(400, 'sessionId 必填')
79
+ const holdParam = url.searchParams.get('hold')
80
+ const hold = holdParam === null ? DEFAULT_POLL_HOLD_MS : Math.max(0, Math.min(DEFAULT_POLL_HOLD_MS, Number(holdParam) || 0))
81
+ const operations = await store.poll(sessionId, hold, request.signal)
82
+ const response: OperationsPollResponse = { operations }
83
+ return Response.json(response, { headers: { 'cache-control': 'no-store' } })
84
+ },
85
+ },
86
+ {
87
+ path: SETTLE_PATH,
88
+ methods: ['POST'],
89
+ requestBody: 'buffered',
90
+ fetch: async (request: Request): Promise<Response> => {
91
+ const body = await readJson(request)
92
+ if (!body.ok) return body.response
93
+ const payload = body.value as { sessionId?: unknown; result?: unknown } | null
94
+ if (typeof payload !== 'object' || payload === null || typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
95
+ return fail(400, 'sessionId 必填')
96
+ }
97
+ const result = narrowResult(payload.result)
98
+ if (result === undefined) return fail(400, 'result 缺字段')
99
+ const accepted = store.settle(payload.sessionId, result)
100
+ const response: OperationSettleResponse = { accepted }
101
+ return Response.json(response)
102
+ },
103
+ },
104
+ ]
105
+ }
106
+
107
+ /**
108
+ * Register every bridge route for the lifetime of `ctx`.
109
+ *
110
+ * @param ctx - Plugin context; registrations are disposed with it.
111
+ * @param store - The bridge's Session-keyed state.
112
+ */
113
+ export function registerSurfaceBridgeRoutes(ctx: Context, store: SurfaceBridgeStore): void {
114
+ for (const route of surfaceBridgeRoutes(store)) {
115
+ ctx.effect(() => ctx.connection.fetch.register(route), `dsh-surface-bridge: ${route.path}`)
116
+ }
117
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The bridge's Host service face: what a business plugin's tools call.
3
+ *
4
+ * A business plugin never touches the store or the routes. It asks three
5
+ * questions — "what is selected", "is the surface open", "please do this" — and
6
+ * the bridge owns how each is answered. "What is selected" is a *question*, not a
7
+ * cached value: nothing is remembered between steps. Keeping the face this small is what makes
8
+ * a second surface adoptable: nothing in it is canvas-shaped.
9
+ *
10
+ * @module dsh-surface-bridge/host/service
11
+ */
12
+
13
+ import type {
14
+ SurfaceApplyOperation,
15
+ SurfaceBridgeHostFace,
16
+ SurfaceOperationResult,
17
+ SurfaceSelection,
18
+ } from '../contract.ts'
19
+ import { narrowSelection } from './narrow.ts'
20
+ import { DEFAULT_OPERATION_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS, SurfaceBridgeStore } from './store.ts'
21
+
22
+ export type { SurfaceApplyOperation, SurfaceBridgeHostFace } from '../contract.ts'
23
+
24
+ /** The bridge's Host service implementation. */
25
+ export class SurfaceBridgeHost implements SurfaceBridgeHostFace {
26
+ private readonly store: SurfaceBridgeStore
27
+
28
+ /** @param store - Shared Session-keyed bridge state. */
29
+ constructor(store: SurfaceBridgeStore) {
30
+ this.store = store
31
+ }
32
+
33
+ /** @inheritdoc */
34
+ async readSelections(sessionId: string, signal?: AbortSignal): Promise<readonly SurfaceSelection[] | undefined> {
35
+ return await this.readInternal(sessionId, signal, false)
36
+ }
37
+
38
+ /** @inheritdoc */
39
+ async consumeSelections(sessionId: string, signal?: AbortSignal): Promise<readonly SurfaceSelection[] | undefined> {
40
+ return await this.readInternal(sessionId, signal, true)
41
+ }
42
+
43
+ /**
44
+ * One read, peeking or consuming.
45
+ *
46
+ * @param sessionId - Session whose surfaces are asked.
47
+ * @param signal - Aborts the wait when the turn is cancelled.
48
+ * @param consume - Whether the surface should spend its selection answering.
49
+ * @returns the valid selections that came back.
50
+ */
51
+ private async readInternal(
52
+ sessionId: string,
53
+ signal: AbortSignal | undefined,
54
+ consume: boolean,
55
+ ): Promise<readonly SurfaceSelection[] | undefined> {
56
+ const answer = await this.store.readSelections(sessionId, DEFAULT_READ_TIMEOUT_MS, signal, consume)
57
+ // Nothing answered: that is not the same fact as "nothing is selected", and the
58
+ // callers that can tell the user the difference must be able to.
59
+ if (answer === undefined) return undefined
60
+ const out: SurfaceSelection[] = []
61
+ for (const entry of answer) {
62
+ // The surface is trusted code, but it is still a browser boundary: a shape
63
+ // the renderer does not expect must not reach a model, and one bad member
64
+ // must not discard the rest of the answer.
65
+ const narrowed = narrowSelection(entry)
66
+ if (narrowed.ok && narrowed.selection.count > 0) out.push(narrowed.selection)
67
+ }
68
+ return out
69
+ }
70
+
71
+ /** @inheritdoc */
72
+ async readSelection(sessionId: string, source: string, signal?: AbortSignal): Promise<SurfaceSelection | null | undefined> {
73
+ const all = await this.readSelections(sessionId, signal)
74
+ if (all === undefined) return undefined
75
+ return all.find(selection => selection.source === source) ?? null
76
+ }
77
+
78
+ /** @inheritdoc */
79
+ isSurfaceLive(sessionId: string): boolean {
80
+ return this.store.isSurfaceLive(sessionId)
81
+ }
82
+
83
+ /** @inheritdoc */
84
+ async apply(
85
+ sessionId: string,
86
+ source: string,
87
+ operations: readonly SurfaceApplyOperation[],
88
+ signal?: AbortSignal,
89
+ ): Promise<readonly SurfaceOperationResult[]> {
90
+ if (operations.length === 0) return []
91
+ if (!this.store.isSurfaceLive(sessionId)) {
92
+ return operations.map(() => ({
93
+ id: '',
94
+ ok: false,
95
+ error: '画布未打开:请在右侧栏打开该画布标签页后重试。',
96
+ }))
97
+ }
98
+ const queued = operations.map(operation => this.store.enqueue(sessionId, source, operation.op, operation.payload))
99
+ return await Promise.all(queued.map(async (operation) => {
100
+ const result = await this.store.awaitResult(sessionId, operation.id, DEFAULT_OPERATION_TIMEOUT_MS, signal)
101
+ if (result === undefined) {
102
+ return {
103
+ id: operation.id,
104
+ ok: false,
105
+ error: '画布未在超时内回传执行结果(可能已关闭或正在重绘)。',
106
+ }
107
+ }
108
+ return result
109
+ }))
110
+ }
111
+
112
+ /** Release one Session's state. */
113
+ forget(sessionId: string): void {
114
+ this.store.forget(sessionId)
115
+ }
116
+ }