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,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
|
+
}
|