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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surface-bridge contract: the vocabulary a business surface (a right-Sidebar
|
|
3
|
+
* canvas, grid, or notebook) uses to hand its current selection to the composer,
|
|
4
|
+
* and the vocabulary the Host uses to turn that selection into model context.
|
|
5
|
+
*
|
|
6
|
+
* The contract is deliberately surface-agnostic. Nothing here mentions a canvas,
|
|
7
|
+
* a cell, or a table: a source projects its own selection into {@link SurfaceElement}
|
|
8
|
+
* rows, and the bridge owns the envelope (revision, count, bounds, the rendered
|
|
9
|
+
* model text, the optional raster). That split is what lets a second surface adopt
|
|
10
|
+
* the seam without the bridge learning what a shape is.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-surface-bridge/contract
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Route paths owned by the bridge.
|
|
16
|
+
*
|
|
17
|
+
* Spelled once, in the module both halves already import, because the browser's
|
|
18
|
+
* `fetch` and the Host's route registration are two halves of one wire: a typo in
|
|
19
|
+
* either would 404 at runtime with no compile-time signal.
|
|
20
|
+
*/
|
|
21
|
+
export const OPS_PATH = '/api/data-canvas/ops';
|
|
22
|
+
export const SETTLE_PATH = '/api/data-canvas/settle';
|
|
23
|
+
/**
|
|
24
|
+
* Reserved operation a Host read uses to ask a live surface what is selected.
|
|
25
|
+
*
|
|
26
|
+
* Its payload carries `consume`: a *consuming* read is one taken to put the selection
|
|
27
|
+
* into a message, and the surface drops its selection once it has answered, so the
|
|
28
|
+
* chip disappears from the composer at exactly the moment the context is spent. A
|
|
29
|
+
* non-consuming read is a peek (a tool asking what is selected) and changes nothing.
|
|
30
|
+
*
|
|
31
|
+
* It carries no source: only the browser knows which surfaces exist and which are
|
|
32
|
+
* on screen, so the Host asks "what has anyone selected?" and gets back a list. The
|
|
33
|
+
* surface answers through the ordinary settle channel with that list in
|
|
34
|
+
* {@link SurfaceOperationResult.value}. One queue item rather than a second channel,
|
|
35
|
+
* so the browser needs no extra listener and a read costs one round trip on a
|
|
36
|
+
* connection that is already parked and idle.
|
|
37
|
+
*/
|
|
38
|
+
export const READ_SELECTION_OP = '__surface-read-selection';
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
import type { SurfaceSelection } from '../contract.ts';
|
|
12
|
+
/** Cap on the selection raster the browser may attach, in base64 characters (~450 KiB of PNG). */
|
|
13
|
+
export declare const MAX_RASTER_BASE64 = 600000;
|
|
14
|
+
/** Cap on elements accepted per selection; the source should already cap lower. */
|
|
15
|
+
export declare const MAX_ELEMENTS = 200;
|
|
16
|
+
/** Cap on attached images per selection. */
|
|
17
|
+
export declare const MAX_IMAGES = 8;
|
|
18
|
+
/** Narrow one published selection, or explain what is wrong with it. */
|
|
19
|
+
export declare function narrowSelection(value: unknown): {
|
|
20
|
+
ok: true;
|
|
21
|
+
selection: SurfaceSelection;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
/** Cap on the selection raster the browser may attach, in base64 characters (~450 KiB of PNG). */
|
|
12
|
+
export const MAX_RASTER_BASE64 = 600_000;
|
|
13
|
+
/** Cap on elements accepted per selection; the source should already cap lower. */
|
|
14
|
+
export const MAX_ELEMENTS = 200;
|
|
15
|
+
/** Cap on attached images per selection. */
|
|
16
|
+
export const MAX_IMAGES = 8;
|
|
17
|
+
/** Narrow a finite number, or `undefined`. */
|
|
18
|
+
function finiteOrUndefined(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Narrow a style bag to short string/number entries. */
|
|
22
|
+
function narrowStyle(value) {
|
|
23
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
24
|
+
return undefined;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
27
|
+
if (typeof entry === 'string' || typeof entry === 'number')
|
|
28
|
+
out[key] = entry;
|
|
29
|
+
}
|
|
30
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
31
|
+
}
|
|
32
|
+
/** Narrow an asset locator bag. */
|
|
33
|
+
function narrowAsset(value) {
|
|
34
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
38
|
+
if (typeof entry === 'string')
|
|
39
|
+
out[key] = entry;
|
|
40
|
+
}
|
|
41
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
42
|
+
}
|
|
43
|
+
/** Narrow a string array. */
|
|
44
|
+
function narrowStringArray(value) {
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
return undefined;
|
|
47
|
+
const out = value.filter((entry) => typeof entry === 'string');
|
|
48
|
+
return out.length === 0 ? undefined : out;
|
|
49
|
+
}
|
|
50
|
+
/** Narrow one element projection; `undefined` rejects the whole selection. */
|
|
51
|
+
function narrowElement(value) {
|
|
52
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
53
|
+
return undefined;
|
|
54
|
+
const raw = value;
|
|
55
|
+
const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : undefined;
|
|
56
|
+
const type = typeof raw.type === 'string' && raw.type.length > 0 ? raw.type : undefined;
|
|
57
|
+
if (id === undefined || type === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const x = finiteOrUndefined(raw.x);
|
|
60
|
+
const y = finiteOrUndefined(raw.y);
|
|
61
|
+
const width = finiteOrUndefined(raw.width);
|
|
62
|
+
const height = finiteOrUndefined(raw.height);
|
|
63
|
+
if (x === undefined || y === undefined || width === undefined || height === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
const linksRaw = raw.links;
|
|
66
|
+
let links;
|
|
67
|
+
if (typeof linksRaw === 'object' && linksRaw !== null && !Array.isArray(linksRaw)) {
|
|
68
|
+
const bag = linksRaw;
|
|
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) }),
|
|
74
|
+
};
|
|
75
|
+
if (Object.keys(links).length === 0)
|
|
76
|
+
links = undefined;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
type,
|
|
81
|
+
label: typeof raw.label === 'string' ? raw.label : type,
|
|
82
|
+
...(typeof raw.text === 'string' ? { text: raw.text } : {}),
|
|
83
|
+
x,
|
|
84
|
+
y,
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
...(finiteOrUndefined(raw.angle) === undefined ? {} : { angle: finiteOrUndefined(raw.angle) }),
|
|
88
|
+
...(narrowStyle(raw.style) === undefined ? {} : { style: narrowStyle(raw.style) }),
|
|
89
|
+
...(narrowAsset(raw.asset) === undefined ? {} : { asset: narrowAsset(raw.asset) }),
|
|
90
|
+
...(links === undefined ? {} : { links }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Narrow one raster; an oversized or malformed raster is dropped, never the selection. */
|
|
94
|
+
function narrowRaster(value) {
|
|
95
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
96
|
+
return undefined;
|
|
97
|
+
const raw = value;
|
|
98
|
+
if (raw.mediaType !== 'image/png' && raw.mediaType !== 'image/jpeg' && raw.mediaType !== 'image/webp' && raw.mediaType !== 'image/gif')
|
|
99
|
+
return undefined;
|
|
100
|
+
if (typeof raw.data !== 'string' || raw.data.length === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (raw.data.length > MAX_RASTER_BASE64)
|
|
103
|
+
return undefined;
|
|
104
|
+
return {
|
|
105
|
+
mediaType: 'image/png',
|
|
106
|
+
data: raw.data,
|
|
107
|
+
...(typeof raw.name === 'string' ? { name: raw.name } : {}),
|
|
108
|
+
...(typeof raw.elementId === 'string' ? { elementId: raw.elementId } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Narrow the attached image list, keeping only well-formed members. */
|
|
112
|
+
function narrowImages(value) {
|
|
113
|
+
if (!Array.isArray(value))
|
|
114
|
+
return undefined;
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const entry of value.slice(0, MAX_IMAGES)) {
|
|
117
|
+
const image = narrowRaster(entry);
|
|
118
|
+
if (image !== undefined)
|
|
119
|
+
out.push(image);
|
|
120
|
+
}
|
|
121
|
+
return out.length === 0 ? undefined : out;
|
|
122
|
+
}
|
|
123
|
+
/** Narrow the document a selection came from; a malformed locator is dropped, not the selection. */
|
|
124
|
+
function narrowResource(value) {
|
|
125
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
126
|
+
return undefined;
|
|
127
|
+
const raw = value;
|
|
128
|
+
const resource = {};
|
|
129
|
+
if (typeof raw.path === 'string' && raw.path.length > 0)
|
|
130
|
+
resource.path = raw.path;
|
|
131
|
+
if (typeof raw.name === 'string' && raw.name.length > 0)
|
|
132
|
+
resource.name = raw.name;
|
|
133
|
+
const version = finiteOrUndefined(raw.version);
|
|
134
|
+
if (version !== undefined)
|
|
135
|
+
resource.version = version;
|
|
136
|
+
return Object.keys(resource).length === 0 ? undefined : resource;
|
|
137
|
+
}
|
|
138
|
+
/** Narrow one published selection, or explain what is wrong with it. */
|
|
139
|
+
export function narrowSelection(value) {
|
|
140
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
141
|
+
return { ok: false, error: 'selection 必须是对象' };
|
|
142
|
+
const raw = value;
|
|
143
|
+
const source = typeof raw.source === 'string' && raw.source.length > 0 ? raw.source : undefined;
|
|
144
|
+
if (source === undefined)
|
|
145
|
+
return { ok: false, error: 'selection.source 必填' };
|
|
146
|
+
const revision = finiteOrUndefined(raw.revision);
|
|
147
|
+
if (revision === undefined || !Number.isSafeInteger(revision) || revision < 0) {
|
|
148
|
+
return { ok: false, error: 'selection.revision 必须是非负安全整数' };
|
|
149
|
+
}
|
|
150
|
+
const count = finiteOrUndefined(raw.count);
|
|
151
|
+
if (count === undefined || !Number.isSafeInteger(count) || count < 0) {
|
|
152
|
+
return { ok: false, error: 'selection.count 必须是非负安全整数' };
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(raw.elements))
|
|
155
|
+
return { ok: false, error: 'selection.elements 必须是数组' };
|
|
156
|
+
if (raw.elements.length > MAX_ELEMENTS)
|
|
157
|
+
return { ok: false, error: `selection.elements 超过上限 ${MAX_ELEMENTS}` };
|
|
158
|
+
const elements = [];
|
|
159
|
+
for (const entry of raw.elements) {
|
|
160
|
+
const element = narrowElement(entry);
|
|
161
|
+
if (element === undefined)
|
|
162
|
+
return { ok: false, error: 'selection.elements 存在缺字段的元素' };
|
|
163
|
+
elements.push(element);
|
|
164
|
+
}
|
|
165
|
+
const boundsRaw = raw.bounds;
|
|
166
|
+
let bounds;
|
|
167
|
+
if (typeof boundsRaw === 'object' && boundsRaw !== null && !Array.isArray(boundsRaw)) {
|
|
168
|
+
const bag = boundsRaw;
|
|
169
|
+
const x = finiteOrUndefined(bag.x);
|
|
170
|
+
const y = finiteOrUndefined(bag.y);
|
|
171
|
+
const width = finiteOrUndefined(bag.width);
|
|
172
|
+
const height = finiteOrUndefined(bag.height);
|
|
173
|
+
if (x !== undefined && y !== undefined && width !== undefined && height !== undefined)
|
|
174
|
+
bounds = { x, y, width, height };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
selection: {
|
|
179
|
+
source,
|
|
180
|
+
revision,
|
|
181
|
+
count,
|
|
182
|
+
title: typeof raw.title === 'string' && raw.title.length > 0 ? raw.title : source,
|
|
183
|
+
summary: typeof raw.summary === 'string' ? raw.summary : `${count} 个元素`,
|
|
184
|
+
elements,
|
|
185
|
+
...(raw.truncated === true ? { truncated: true } : {}),
|
|
186
|
+
...(narrowResource(raw.resource) === undefined ? {} : { resource: narrowResource(raw.resource) }),
|
|
187
|
+
...(bounds === undefined ? {} : { bounds }),
|
|
188
|
+
...(narrowImages(raw.images) === undefined ? {} : { images: narrowImages(raw.images) }),
|
|
189
|
+
...(narrowStringArray(raw.notes) === undefined ? {} : { notes: narrowStringArray(raw.notes) }),
|
|
190
|
+
...(narrowStringArray(raw.capabilities) === undefined ? {} : { capabilities: narrowStringArray(raw.capabilities) }),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
import type { SurfaceElement, SurfaceSelection } from '../contract.ts';
|
|
18
|
+
/** Maximum element rows rendered before the text degrades to a count. */
|
|
19
|
+
export declare const MAX_RENDERED_ELEMENTS = 40;
|
|
20
|
+
/** Render one element row. */
|
|
21
|
+
export declare function renderElementRow(element: SurfaceElement): string;
|
|
22
|
+
/** How many element names the visible summary line carries before it degrades to `等`. */
|
|
23
|
+
export declare const MAX_CHIP_NAMES = 3;
|
|
24
|
+
/**
|
|
25
|
+
* Width budget for the visible line, in half-width units.
|
|
26
|
+
*
|
|
27
|
+
* A user bubble is about 493px of usable width at the shell's 14px content font (max-width is
|
|
28
|
+
* `min(748px * .702, 82%)` minus 16px of padding each side), and a CJK glyph there is roughly
|
|
29
|
+
* 7px per unit of this measure — so ~70 units fit. The budget is set below that on purpose:
|
|
30
|
+
* the estimate is a proxy, and the failure mode it buys insurance against is the line wrapping
|
|
31
|
+
* to a second row, which is exactly what the request was about.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAX_CHIP_LINE_UNITS = 56;
|
|
34
|
+
/** Width budget for the document name on the visible line, in half-width units. */
|
|
35
|
+
export declare const MAX_CHIP_FILE_UNITS = 24;
|
|
36
|
+
/**
|
|
37
|
+
* Render the one line the person reads in the transcript.
|
|
38
|
+
*
|
|
39
|
+
* The element table stays in the hidden detail row, because a bubble of coordinates is not
|
|
40
|
+
* something a person wants in their conversation. What they do want is to recognise what they
|
|
41
|
+
* sent: the document, how many elements, and — when they fit — the first few names.
|
|
42
|
+
*
|
|
43
|
+
* **One line, by construction.** The line carries the document and the count, then as many
|
|
44
|
+
* names as the width budget allows; a name that would push it onto a second row is dropped
|
|
45
|
+
* rather than wrapped. A selection of unlabelled shapes (the common case for a drawing someone
|
|
46
|
+
* just made) therefore reads exactly `画布选区 · main.excalidraw · 1 个元素`.
|
|
47
|
+
*
|
|
48
|
+
* @param selection - Selection to summarise.
|
|
49
|
+
* @returns one line, e.g. `画布选区 · main.excalidraw · 2 个元素:下单、风控`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderSelectionChip(selection: SurfaceSelection): string;
|
|
52
|
+
/**
|
|
53
|
+
* Render one selection as the text that enters the model step.
|
|
54
|
+
*
|
|
55
|
+
* This is the hidden detail row: every actionable fact, and nothing written for a person to
|
|
56
|
+
* read — the visible line is {@link renderSelectionChip}. The two are deliberately different
|
|
57
|
+
* texts rather than one text shown twice.
|
|
58
|
+
*
|
|
59
|
+
* @param selection - Selection to render.
|
|
60
|
+
* @param selectionRef - Short stable handle for this selection, echoed by the tools.
|
|
61
|
+
* @returns the model-facing context text.
|
|
62
|
+
*/
|
|
63
|
+
export declare function renderSelectionText(selection: SurfaceSelection, selectionRef: string): string;
|
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
/** Maximum element rows rendered before the text degrades to a count. */
|
|
18
|
+
export const MAX_RENDERED_ELEMENTS = 40;
|
|
19
|
+
/** Round a coordinate to one decimal; the precision a diagram actually needs. */
|
|
20
|
+
function round(value) {
|
|
21
|
+
if (!Number.isFinite(value))
|
|
22
|
+
return 0;
|
|
23
|
+
return Math.round(value * 10) / 10;
|
|
24
|
+
}
|
|
25
|
+
/** Render one style bag as `key=value` pairs, sorted for a stable prompt. */
|
|
26
|
+
function renderStyle(style) {
|
|
27
|
+
if (style === undefined)
|
|
28
|
+
return '';
|
|
29
|
+
const entries = Object.entries(style);
|
|
30
|
+
if (entries.length === 0)
|
|
31
|
+
return '';
|
|
32
|
+
const rendered = entries
|
|
33
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
34
|
+
.map(([key, value]) => `${key}=${typeof value === 'number' ? round(value) : value}`);
|
|
35
|
+
return ` style{${rendered.join(',')}}`;
|
|
36
|
+
}
|
|
37
|
+
/** Render one asset locator bag, sorted for a stable prompt. */
|
|
38
|
+
function renderAsset(asset) {
|
|
39
|
+
if (asset === undefined)
|
|
40
|
+
return '';
|
|
41
|
+
const entries = Object.entries(asset);
|
|
42
|
+
if (entries.length === 0)
|
|
43
|
+
return '';
|
|
44
|
+
const rendered = entries
|
|
45
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
46
|
+
.map(([key, value]) => `${key}=${value}`);
|
|
47
|
+
return ` asset{${rendered.join(',')}}`;
|
|
48
|
+
}
|
|
49
|
+
/** Render one element's connection relations. */
|
|
50
|
+
function renderLinks(element) {
|
|
51
|
+
const links = element.links;
|
|
52
|
+
if (links === undefined)
|
|
53
|
+
return '';
|
|
54
|
+
const parts = [];
|
|
55
|
+
if (links.from !== undefined)
|
|
56
|
+
parts.push(`from=${links.from}`);
|
|
57
|
+
if (links.to !== undefined)
|
|
58
|
+
parts.push(`to=${links.to}`);
|
|
59
|
+
if (links.container !== undefined)
|
|
60
|
+
parts.push(`container=${links.container}`);
|
|
61
|
+
if (links.bound !== undefined && links.bound.length > 0)
|
|
62
|
+
parts.push(`bound=${links.bound.join('|')}`);
|
|
63
|
+
if (parts.length === 0)
|
|
64
|
+
return '';
|
|
65
|
+
return ` links{${parts.join(',')}}`;
|
|
66
|
+
}
|
|
67
|
+
/** Render one element row. */
|
|
68
|
+
export function renderElementRow(element) {
|
|
69
|
+
const text = element.text === undefined || element.text.length === 0
|
|
70
|
+
? ''
|
|
71
|
+
: ` text=${JSON.stringify(element.text)}`;
|
|
72
|
+
const angle = element.angle === undefined || round(element.angle) === 0 ? '' : ` angle=${round(element.angle)}`;
|
|
73
|
+
return `- ${element.type} id=${element.id}${text} @(${round(element.x)},${round(element.y)}) `
|
|
74
|
+
+ `${round(element.width)}x${round(element.height)}${angle}`
|
|
75
|
+
+ `${renderStyle(element.style)}${renderAsset(element.asset)}${renderLinks(element)}`;
|
|
76
|
+
}
|
|
77
|
+
/** How many element names the visible summary line carries before it degrades to `等`. */
|
|
78
|
+
export const MAX_CHIP_NAMES = 3;
|
|
79
|
+
/** How long one element name may be on the visible summary line. */
|
|
80
|
+
const MAX_CHIP_NAME_CHARS = 18;
|
|
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
|
+
/** Approximate width of one string in half-width units: CJK counts double. */
|
|
92
|
+
function widthUnits(text) {
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const char of text)
|
|
95
|
+
total += (char.codePointAt(0) ?? 0) > 0x2e80 ? 2 : 1;
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
/** Width budget for the document name on the visible line, in half-width units. */
|
|
99
|
+
export const MAX_CHIP_FILE_UNITS = 24;
|
|
100
|
+
/**
|
|
101
|
+
* Shorten a document name for the visible line, keeping its extension.
|
|
102
|
+
*
|
|
103
|
+
* A 40-character file name would fill the line on its own and push the count onto a second row
|
|
104
|
+
* — and the count is the part the reader is checking. The extension stays visible because it is
|
|
105
|
+
* the part that says what kind of thing this is; the full name is one row down, in the detail.
|
|
106
|
+
*
|
|
107
|
+
* @param name - Document name.
|
|
108
|
+
* @returns the name, or a shortened form ending in `…<extension>`.
|
|
109
|
+
*/
|
|
110
|
+
function shortFileName(name) {
|
|
111
|
+
if (widthUnits(name) <= MAX_CHIP_FILE_UNITS)
|
|
112
|
+
return name;
|
|
113
|
+
const dot = name.lastIndexOf('.');
|
|
114
|
+
const extension = dot > 0 ? name.slice(dot) : '';
|
|
115
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
116
|
+
const room = MAX_CHIP_FILE_UNITS - widthUnits(extension) - 1;
|
|
117
|
+
if (room <= 0)
|
|
118
|
+
return `${name.slice(0, MAX_CHIP_FILE_UNITS - 1)}…`;
|
|
119
|
+
let kept = '';
|
|
120
|
+
for (const char of stem) {
|
|
121
|
+
if (widthUnits(kept + char) > room)
|
|
122
|
+
break;
|
|
123
|
+
kept += char;
|
|
124
|
+
}
|
|
125
|
+
// A separator left dangling before the ellipsis reads as a typo: `a-very-long-…` rather than
|
|
126
|
+
// `a-very-long…`.
|
|
127
|
+
kept = kept.replace(/[-_ .]+$/, '');
|
|
128
|
+
return `${kept}…${extension}`;
|
|
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) {
|
|
146
|
+
const parts = [`${selection.title}选区`];
|
|
147
|
+
if (selection.resource?.name !== undefined)
|
|
148
|
+
parts.push(shortFileName(selection.resource.name));
|
|
149
|
+
parts.push(`${selection.count} 个元素`);
|
|
150
|
+
const head = parts.join(' · ');
|
|
151
|
+
const names = (selection.elements ?? [])
|
|
152
|
+
.map(element => element.text ?? element.label)
|
|
153
|
+
.filter((name) => typeof name === 'string' && name.length > 0)
|
|
154
|
+
.slice(0, MAX_CHIP_NAMES)
|
|
155
|
+
.map(name => (name.length > MAX_CHIP_NAME_CHARS ? `${name.slice(0, MAX_CHIP_NAME_CHARS)}…` : name));
|
|
156
|
+
if (names.length === 0)
|
|
157
|
+
return head;
|
|
158
|
+
// Greedy fit: add names while the whole line stays inside the budget. `等` is charged up
|
|
159
|
+
// front when there are more names than the list holds, so the suffix never overflows either.
|
|
160
|
+
const room = MAX_CHIP_LINE_UNITS - widthUnits(head) - 2;
|
|
161
|
+
const fits = [];
|
|
162
|
+
let used = 0;
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
const cost = widthUnits(name) + (fits.length === 0 ? 0 : 2);
|
|
165
|
+
if (used + cost > room)
|
|
166
|
+
break;
|
|
167
|
+
fits.push(name);
|
|
168
|
+
used += cost;
|
|
169
|
+
}
|
|
170
|
+
if (fits.length === 0)
|
|
171
|
+
return head;
|
|
172
|
+
const suffix = selection.count > fits.length ? ' 等' : '';
|
|
173
|
+
return `${head}:${fits.join('、')}${suffix}`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Render one selection as the text that enters the model step.
|
|
177
|
+
*
|
|
178
|
+
* This is the hidden detail row: every actionable fact, and nothing written for a person to
|
|
179
|
+
* read — the visible line is {@link renderSelectionChip}. The two are deliberately different
|
|
180
|
+
* texts rather than one text shown twice.
|
|
181
|
+
*
|
|
182
|
+
* @param selection - Selection to render.
|
|
183
|
+
* @param selectionRef - Short stable handle for this selection, echoed by the tools.
|
|
184
|
+
* @returns the model-facing context text.
|
|
185
|
+
*/
|
|
186
|
+
export function renderSelectionText(selection, selectionRef) {
|
|
187
|
+
const shown = selection.elements.slice(0, MAX_RENDERED_ELEMENTS);
|
|
188
|
+
const lines = [];
|
|
189
|
+
lines.push(`[${selection.title} 选择集 ${selectionRef}] ${selection.summary}`
|
|
190
|
+
+ `(共 ${selection.count} 个元素${selection.truncated === true || shown.length < selection.count ? `,下列只列出前 ${shown.length} 个` : ''})`);
|
|
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
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
for (const element of shown)
|
|
207
|
+
lines.push(renderElementRow(element));
|
|
208
|
+
}
|
|
209
|
+
if (selection.bounds !== undefined) {
|
|
210
|
+
const bounds = selection.bounds;
|
|
211
|
+
lines.push(`选择集包围盒:@(${round(bounds.x)},${round(bounds.y)}) ${round(bounds.width)}x${round(bounds.height)}`);
|
|
212
|
+
}
|
|
213
|
+
const imageElements = selection.elements.filter(element => element.asset !== undefined);
|
|
214
|
+
if (imageElements.length > 0) {
|
|
215
|
+
lines.push(`其中 ${imageElements.length} 个元素带图片素材:字节以图片块附在这次选区的可见消息里,文本里只保留 asset 定位,不要向用户索要图片数据。`);
|
|
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 ?? [])
|
|
224
|
+
lines.push(note);
|
|
225
|
+
for (const capability of selection.capabilities ?? [])
|
|
226
|
+
lines.push(capability);
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
+
import type { ConnectionFetchRoute } from '@deepseek-ai/dsh-client-connection';
|
|
15
|
+
import { OPS_PATH, SETTLE_PATH } from '../contract.ts';
|
|
16
|
+
import type { SurfaceBridgeStore } from './store.ts';
|
|
17
|
+
export { OPS_PATH, SETTLE_PATH };
|
|
18
|
+
/**
|
|
19
|
+
* Build every route the bridge serves.
|
|
20
|
+
*
|
|
21
|
+
* @param store - The bridge's Session-keyed state.
|
|
22
|
+
* @returns the exact Fetch routes to register on DSH's shared API channel.
|
|
23
|
+
*/
|
|
24
|
+
export declare function surfaceBridgeRoutes(store: SurfaceBridgeStore): readonly ConnectionFetchRoute[];
|
|
25
|
+
/**
|
|
26
|
+
* Register every bridge route for the lifetime of `ctx`.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - Plugin context; registrations are disposed with it.
|
|
29
|
+
* @param store - The bridge's Session-keyed state.
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerSurfaceBridgeRoutes(ctx: Context, store: SurfaceBridgeStore): void;
|