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,108 @@
|
|
|
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 { OPS_PATH, SETTLE_PATH } from "../contract.js";
|
|
14
|
+
import { DEFAULT_POLL_HOLD_MS } from "./store.js";
|
|
15
|
+
export { OPS_PATH, SETTLE_PATH };
|
|
16
|
+
/** Build a JSON failure body. */
|
|
17
|
+
function fail(status, error) {
|
|
18
|
+
const body = { ok: false, error };
|
|
19
|
+
return Response.json(body, { status });
|
|
20
|
+
}
|
|
21
|
+
/** Read and parse a JSON body, answering the failure response the route should return. */
|
|
22
|
+
async function readJson(request) {
|
|
23
|
+
const contentType = request.headers.get('content-type') ?? '';
|
|
24
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
25
|
+
return { ok: false, response: fail(415, '请求必须使用 application/json') };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return { ok: true, value: await request.json() };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { ok: false, response: fail(400, '请求体不是合法 JSON') };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Narrow one operation result reported by a surface. */
|
|
35
|
+
function narrowResult(value) {
|
|
36
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
37
|
+
return undefined;
|
|
38
|
+
const raw = value;
|
|
39
|
+
if (typeof raw.id !== 'string' || raw.id.length === 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof raw.ok !== 'boolean')
|
|
42
|
+
return undefined;
|
|
43
|
+
return {
|
|
44
|
+
id: raw.id,
|
|
45
|
+
ok: raw.ok,
|
|
46
|
+
...(typeof raw.detail === 'string' ? { detail: raw.detail } : {}),
|
|
47
|
+
...(typeof raw.error === 'string' ? { error: raw.error } : {}),
|
|
48
|
+
// A read answer rides here; the service validates it before anyone reads it.
|
|
49
|
+
...(raw.value === undefined ? {} : { value: raw.value }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build every route the bridge serves.
|
|
54
|
+
*
|
|
55
|
+
* @param store - The bridge's Session-keyed state.
|
|
56
|
+
* @returns the exact Fetch routes to register on DSH's shared API channel.
|
|
57
|
+
*/
|
|
58
|
+
export function surfaceBridgeRoutes(store) {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
path: OPS_PATH,
|
|
62
|
+
methods: ['GET'],
|
|
63
|
+
requestBody: 'buffered',
|
|
64
|
+
fetch: async (request) => {
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
67
|
+
if (sessionId === null || sessionId.length === 0)
|
|
68
|
+
return fail(400, 'sessionId 必填');
|
|
69
|
+
const holdParam = url.searchParams.get('hold');
|
|
70
|
+
const hold = holdParam === null ? DEFAULT_POLL_HOLD_MS : Math.max(0, Math.min(DEFAULT_POLL_HOLD_MS, Number(holdParam) || 0));
|
|
71
|
+
const operations = await store.poll(sessionId, hold, request.signal);
|
|
72
|
+
const response = { operations };
|
|
73
|
+
return Response.json(response, { headers: { 'cache-control': 'no-store' } });
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
path: SETTLE_PATH,
|
|
78
|
+
methods: ['POST'],
|
|
79
|
+
requestBody: 'buffered',
|
|
80
|
+
fetch: async (request) => {
|
|
81
|
+
const body = await readJson(request);
|
|
82
|
+
if (!body.ok)
|
|
83
|
+
return body.response;
|
|
84
|
+
const payload = body.value;
|
|
85
|
+
if (typeof payload !== 'object' || payload === null || typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
|
|
86
|
+
return fail(400, 'sessionId 必填');
|
|
87
|
+
}
|
|
88
|
+
const result = narrowResult(payload.result);
|
|
89
|
+
if (result === undefined)
|
|
90
|
+
return fail(400, 'result 缺字段');
|
|
91
|
+
const accepted = store.settle(payload.sessionId, result);
|
|
92
|
+
const response = { accepted };
|
|
93
|
+
return Response.json(response);
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Register every bridge route for the lifetime of `ctx`.
|
|
100
|
+
*
|
|
101
|
+
* @param ctx - Plugin context; registrations are disposed with it.
|
|
102
|
+
* @param store - The bridge's Session-keyed state.
|
|
103
|
+
*/
|
|
104
|
+
export function registerSurfaceBridgeRoutes(ctx, store) {
|
|
105
|
+
for (const route of surfaceBridgeRoutes(store)) {
|
|
106
|
+
ctx.effect(() => ctx.connection.fetch.register(route), `dsh-surface-bridge: ${route.path}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import type { SurfaceApplyOperation, SurfaceBridgeHostFace, SurfaceOperationResult, SurfaceSelection } from '../contract.ts';
|
|
13
|
+
import { SurfaceBridgeStore } from './store.ts';
|
|
14
|
+
export type { SurfaceApplyOperation, SurfaceBridgeHostFace } from '../contract.ts';
|
|
15
|
+
/** The bridge's Host service implementation. */
|
|
16
|
+
export declare class SurfaceBridgeHost implements SurfaceBridgeHostFace {
|
|
17
|
+
private readonly store;
|
|
18
|
+
/** @param store - Shared Session-keyed bridge state. */
|
|
19
|
+
constructor(store: SurfaceBridgeStore);
|
|
20
|
+
/** @inheritdoc */
|
|
21
|
+
readSelections(sessionId: string, signal?: AbortSignal): Promise<readonly SurfaceSelection[] | undefined>;
|
|
22
|
+
/** @inheritdoc */
|
|
23
|
+
consumeSelections(sessionId: string, signal?: AbortSignal): Promise<readonly SurfaceSelection[] | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* One read, peeking or consuming.
|
|
26
|
+
*
|
|
27
|
+
* @param sessionId - Session whose surfaces are asked.
|
|
28
|
+
* @param signal - Aborts the wait when the turn is cancelled.
|
|
29
|
+
* @param consume - Whether the surface should spend its selection answering.
|
|
30
|
+
* @returns the valid selections that came back.
|
|
31
|
+
*/
|
|
32
|
+
private readInternal;
|
|
33
|
+
/** @inheritdoc */
|
|
34
|
+
readSelection(sessionId: string, source: string, signal?: AbortSignal): Promise<SurfaceSelection | null | undefined>;
|
|
35
|
+
/** @inheritdoc */
|
|
36
|
+
isSurfaceLive(sessionId: string): boolean;
|
|
37
|
+
/** @inheritdoc */
|
|
38
|
+
apply(sessionId: string, source: string, operations: readonly SurfaceApplyOperation[], signal?: AbortSignal): Promise<readonly SurfaceOperationResult[]>;
|
|
39
|
+
/** Release one Session's state. */
|
|
40
|
+
forget(sessionId: string): void;
|
|
41
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
import { narrowSelection } from "./narrow.js";
|
|
13
|
+
import { DEFAULT_OPERATION_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS } from "./store.js";
|
|
14
|
+
/** The bridge's Host service implementation. */
|
|
15
|
+
export class SurfaceBridgeHost {
|
|
16
|
+
store;
|
|
17
|
+
/** @param store - Shared Session-keyed bridge state. */
|
|
18
|
+
constructor(store) {
|
|
19
|
+
this.store = store;
|
|
20
|
+
}
|
|
21
|
+
/** @inheritdoc */
|
|
22
|
+
async readSelections(sessionId, signal) {
|
|
23
|
+
return await this.readInternal(sessionId, signal, false);
|
|
24
|
+
}
|
|
25
|
+
/** @inheritdoc */
|
|
26
|
+
async consumeSelections(sessionId, signal) {
|
|
27
|
+
return await this.readInternal(sessionId, signal, true);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One read, peeking or consuming.
|
|
31
|
+
*
|
|
32
|
+
* @param sessionId - Session whose surfaces are asked.
|
|
33
|
+
* @param signal - Aborts the wait when the turn is cancelled.
|
|
34
|
+
* @param consume - Whether the surface should spend its selection answering.
|
|
35
|
+
* @returns the valid selections that came back.
|
|
36
|
+
*/
|
|
37
|
+
async readInternal(sessionId, signal, consume) {
|
|
38
|
+
const answer = await this.store.readSelections(sessionId, DEFAULT_READ_TIMEOUT_MS, signal, consume);
|
|
39
|
+
// Nothing answered: that is not the same fact as "nothing is selected", and the
|
|
40
|
+
// callers that can tell the user the difference must be able to.
|
|
41
|
+
if (answer === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const entry of answer) {
|
|
45
|
+
// The surface is trusted code, but it is still a browser boundary: a shape
|
|
46
|
+
// the renderer does not expect must not reach a model, and one bad member
|
|
47
|
+
// must not discard the rest of the answer.
|
|
48
|
+
const narrowed = narrowSelection(entry);
|
|
49
|
+
if (narrowed.ok && narrowed.selection.count > 0)
|
|
50
|
+
out.push(narrowed.selection);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** @inheritdoc */
|
|
55
|
+
async readSelection(sessionId, source, signal) {
|
|
56
|
+
const all = await this.readSelections(sessionId, signal);
|
|
57
|
+
if (all === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
return all.find(selection => selection.source === source) ?? null;
|
|
60
|
+
}
|
|
61
|
+
/** @inheritdoc */
|
|
62
|
+
isSurfaceLive(sessionId) {
|
|
63
|
+
return this.store.isSurfaceLive(sessionId);
|
|
64
|
+
}
|
|
65
|
+
/** @inheritdoc */
|
|
66
|
+
async apply(sessionId, source, operations, signal) {
|
|
67
|
+
if (operations.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
if (!this.store.isSurfaceLive(sessionId)) {
|
|
70
|
+
return operations.map(() => ({
|
|
71
|
+
id: '',
|
|
72
|
+
ok: false,
|
|
73
|
+
error: '画布未打开:请在右侧栏打开该画布标签页后重试。',
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
const queued = operations.map(operation => this.store.enqueue(sessionId, source, operation.op, operation.payload));
|
|
77
|
+
return await Promise.all(queued.map(async (operation) => {
|
|
78
|
+
const result = await this.store.awaitResult(sessionId, operation.id, DEFAULT_OPERATION_TIMEOUT_MS, signal);
|
|
79
|
+
if (result === undefined) {
|
|
80
|
+
return {
|
|
81
|
+
id: operation.id,
|
|
82
|
+
ok: false,
|
|
83
|
+
error: '画布未在超时内回传执行结果(可能已关闭或正在重绘)。',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/** Release one Session's state. */
|
|
90
|
+
forget(sessionId) {
|
|
91
|
+
this.store.forget(sessionId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side state of the surface bridge: one pending selection and one operation
|
|
3
|
+
* queue per Session, plus the liveness stamp that lets a write-back tool fail
|
|
4
|
+
* loudly instead of pretending a closed surface accepted its edit.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is in-memory and process-local by design. A selection is a
|
|
7
|
+
* snapshot of what the user is looking at right now; persisting it across a
|
|
8
|
+
* restart would resurrect a context the user never chose in this process. The
|
|
9
|
+
* operation queue is a handoff channel between two halves of one running app,
|
|
10
|
+
* so it has no meaning once either half is gone.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-surface-bridge/host/store
|
|
13
|
+
*/
|
|
14
|
+
import type { SurfaceOperation, SurfaceOperationResult } from '../contract.ts';
|
|
15
|
+
/** How long a poll may hold before it answers empty, in milliseconds. */
|
|
16
|
+
export declare const DEFAULT_POLL_HOLD_MS = 20000;
|
|
17
|
+
/** How long after the last poll a surface is still considered open. */
|
|
18
|
+
export declare const SURFACE_LIVENESS_MS = 45000;
|
|
19
|
+
/** How long a write-back tool waits for the surface to report a result. */
|
|
20
|
+
export declare const DEFAULT_OPERATION_TIMEOUT_MS = 25000;
|
|
21
|
+
/**
|
|
22
|
+
* How long a model step waits for a surface to answer a read.
|
|
23
|
+
*
|
|
24
|
+
* Short on purpose: a live surface is already parked on a poll, so an answer is a
|
|
25
|
+
* round trip on an idle connection. If it does not come, the step proceeds without
|
|
26
|
+
* the selection rather than delaying the user's message.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DEFAULT_READ_TIMEOUT_MS = 600;
|
|
29
|
+
/** The bridge's Host state, keyed by Session id. */
|
|
30
|
+
export declare class SurfaceBridgeStore {
|
|
31
|
+
private readonly sessions;
|
|
32
|
+
/** Resolve one Session's state, creating it on first use. */
|
|
33
|
+
private stateOf;
|
|
34
|
+
/** Wake every waiter of one Session. */
|
|
35
|
+
private wake;
|
|
36
|
+
/** Whether any surface of one Session has polled recently enough to be considered open. */
|
|
37
|
+
isSurfaceLive(sessionId: string, now?: number): boolean;
|
|
38
|
+
/** Queue one operation for a surface and return it with its minted id. */
|
|
39
|
+
enqueue(sessionId: string, source: string, op: string, payload: unknown): SurfaceOperation;
|
|
40
|
+
/**
|
|
41
|
+
* Ask a live surface what it has selected, and wait for its answer.
|
|
42
|
+
*
|
|
43
|
+
* Implemented on the operation queue rather than as its own channel: the browser
|
|
44
|
+
* already holds a poll open, so a read is one enqueue plus the answer the loop
|
|
45
|
+
* sends back. A surface that is closed answers nothing, and the read gives up
|
|
46
|
+
* rather than holding a model step open.
|
|
47
|
+
*
|
|
48
|
+
* @param sessionId - Session whose surfaces are asked.
|
|
49
|
+
* @param timeoutMs - Bound on the wait.
|
|
50
|
+
* @param signal - Aborts the wait when the turn is cancelled.
|
|
51
|
+
* @param consume - Whether the surface should spend its selection answering this.
|
|
52
|
+
* @returns the raw answer (an array of selections), or `undefined` when nothing answered.
|
|
53
|
+
*/
|
|
54
|
+
readSelections(sessionId: string, timeoutMs: number, signal?: AbortSignal, consume?: boolean): Promise<readonly unknown[] | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* Take the operations waiting for one Session, holding the request open until
|
|
57
|
+
* something arrives or the hold expires.
|
|
58
|
+
*
|
|
59
|
+
* A long poll rather than a socket: the surface already runs a fetch loop, and
|
|
60
|
+
* a held request is one line of client code with no reconnect protocol of its
|
|
61
|
+
* own. The hold is bounded so a broken connection cannot pin a handler forever.
|
|
62
|
+
*
|
|
63
|
+
* @param sessionId - Session whose surface is polling.
|
|
64
|
+
* @param holdMs - Maximum time to hold before answering empty.
|
|
65
|
+
* @param signal - Aborts the hold when the browser goes away.
|
|
66
|
+
* @returns the queued operations, oldest first.
|
|
67
|
+
*/
|
|
68
|
+
poll(sessionId: string, holdMs: number, signal?: AbortSignal): Promise<readonly SurfaceOperation[]>;
|
|
69
|
+
/** Record a surface-reported result and wake whoever is waiting for it. */
|
|
70
|
+
settle(sessionId: string, result: SurfaceOperationResult): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Wait for one operation's result.
|
|
73
|
+
*
|
|
74
|
+
* @param sessionId - Session that owns the operation.
|
|
75
|
+
* @param operationId - Operation to await.
|
|
76
|
+
* @param timeoutMs - Bound on the wait; a timeout is a real failure the caller reports.
|
|
77
|
+
* @param signal - Aborts the wait when the Agent turn is cancelled.
|
|
78
|
+
* @returns the result, or `undefined` when the surface never reported.
|
|
79
|
+
*/
|
|
80
|
+
awaitResult(sessionId: string, operationId: string, timeoutMs: number, signal?: AbortSignal): Promise<SurfaceOperationResult | undefined>;
|
|
81
|
+
/** Forget one Session's whole bridge state (Session teardown). */
|
|
82
|
+
forget(sessionId: string): void;
|
|
83
|
+
/** Every Session currently holding bridge state; used by teardown sweeps. */
|
|
84
|
+
sessions_(): readonly string[];
|
|
85
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side state of the surface bridge: one pending selection and one operation
|
|
3
|
+
* queue per Session, plus the liveness stamp that lets a write-back tool fail
|
|
4
|
+
* loudly instead of pretending a closed surface accepted its edit.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is in-memory and process-local by design. A selection is a
|
|
7
|
+
* snapshot of what the user is looking at right now; persisting it across a
|
|
8
|
+
* restart would resurrect a context the user never chose in this process. The
|
|
9
|
+
* operation queue is a handoff channel between two halves of one running app,
|
|
10
|
+
* so it has no meaning once either half is gone.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-surface-bridge/host/store
|
|
13
|
+
*/
|
|
14
|
+
import { READ_SELECTION_OP } from "../contract.js";
|
|
15
|
+
/** How long a poll may hold before it answers empty, in milliseconds. */
|
|
16
|
+
export const DEFAULT_POLL_HOLD_MS = 20_000;
|
|
17
|
+
/** How long after the last poll a surface is still considered open. */
|
|
18
|
+
export const SURFACE_LIVENESS_MS = 45_000;
|
|
19
|
+
/** How long a write-back tool waits for the surface to report a result. */
|
|
20
|
+
export const DEFAULT_OPERATION_TIMEOUT_MS = 25_000;
|
|
21
|
+
/**
|
|
22
|
+
* How long a model step waits for a surface to answer a read.
|
|
23
|
+
*
|
|
24
|
+
* Short on purpose: a live surface is already parked on a poll, so an answer is a
|
|
25
|
+
* round trip on an idle connection. If it does not come, the step proceeds without
|
|
26
|
+
* the selection rather than delaying the user's message.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_READ_TIMEOUT_MS = 600;
|
|
29
|
+
/** The bridge's Host state, keyed by Session id. */
|
|
30
|
+
export class SurfaceBridgeStore {
|
|
31
|
+
sessions = new Map();
|
|
32
|
+
/** Resolve one Session's state, creating it on first use. */
|
|
33
|
+
stateOf(sessionId) {
|
|
34
|
+
const existing = this.sessions.get(sessionId);
|
|
35
|
+
if (existing !== undefined)
|
|
36
|
+
return existing;
|
|
37
|
+
const created = {
|
|
38
|
+
queued: [],
|
|
39
|
+
settled: new Map(),
|
|
40
|
+
waiters: new Set(),
|
|
41
|
+
lastPollAt: 0,
|
|
42
|
+
operationSeq: 0,
|
|
43
|
+
released: false,
|
|
44
|
+
};
|
|
45
|
+
this.sessions.set(sessionId, created);
|
|
46
|
+
return created;
|
|
47
|
+
}
|
|
48
|
+
/** Wake every waiter of one Session. */
|
|
49
|
+
wake(state) {
|
|
50
|
+
for (const waiter of [...state.waiters])
|
|
51
|
+
waiter();
|
|
52
|
+
}
|
|
53
|
+
/** Whether any surface of one Session has polled recently enough to be considered open. */
|
|
54
|
+
isSurfaceLive(sessionId, now = Date.now()) {
|
|
55
|
+
const state = this.sessions.get(sessionId);
|
|
56
|
+
if (state === undefined)
|
|
57
|
+
return false;
|
|
58
|
+
return now - state.lastPollAt <= SURFACE_LIVENESS_MS;
|
|
59
|
+
}
|
|
60
|
+
/** Queue one operation for a surface and return it with its minted id. */
|
|
61
|
+
enqueue(sessionId, source, op, payload) {
|
|
62
|
+
const state = this.stateOf(sessionId);
|
|
63
|
+
state.operationSeq += 1;
|
|
64
|
+
const operation = {
|
|
65
|
+
id: `${sessionId}:${state.operationSeq}`,
|
|
66
|
+
source,
|
|
67
|
+
op,
|
|
68
|
+
payload,
|
|
69
|
+
};
|
|
70
|
+
state.queued.push(operation);
|
|
71
|
+
this.wake(state);
|
|
72
|
+
return operation;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Ask a live surface what it has selected, and wait for its answer.
|
|
76
|
+
*
|
|
77
|
+
* Implemented on the operation queue rather than as its own channel: the browser
|
|
78
|
+
* already holds a poll open, so a read is one enqueue plus the answer the loop
|
|
79
|
+
* sends back. A surface that is closed answers nothing, and the read gives up
|
|
80
|
+
* rather than holding a model step open.
|
|
81
|
+
*
|
|
82
|
+
* @param sessionId - Session whose surfaces are asked.
|
|
83
|
+
* @param timeoutMs - Bound on the wait.
|
|
84
|
+
* @param signal - Aborts the wait when the turn is cancelled.
|
|
85
|
+
* @param consume - Whether the surface should spend its selection answering this.
|
|
86
|
+
* @returns the raw answer (an array of selections), or `undefined` when nothing answered.
|
|
87
|
+
*/
|
|
88
|
+
async readSelections(sessionId, timeoutMs, signal, consume = false) {
|
|
89
|
+
// No answer can come if nothing is polling: the surface is closed, or its tab
|
|
90
|
+
// was never opened in this Session.
|
|
91
|
+
if (!this.isSurfaceLive(sessionId))
|
|
92
|
+
return undefined;
|
|
93
|
+
const request = this.enqueue(sessionId, '*', READ_SELECTION_OP, { consume });
|
|
94
|
+
const result = await this.awaitResult(sessionId, request.id, timeoutMs, signal);
|
|
95
|
+
if (result === undefined || !result.ok)
|
|
96
|
+
return undefined;
|
|
97
|
+
return Array.isArray(result.value) ? result.value : [];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Take the operations waiting for one Session, holding the request open until
|
|
101
|
+
* something arrives or the hold expires.
|
|
102
|
+
*
|
|
103
|
+
* A long poll rather than a socket: the surface already runs a fetch loop, and
|
|
104
|
+
* a held request is one line of client code with no reconnect protocol of its
|
|
105
|
+
* own. The hold is bounded so a broken connection cannot pin a handler forever.
|
|
106
|
+
*
|
|
107
|
+
* @param sessionId - Session whose surface is polling.
|
|
108
|
+
* @param holdMs - Maximum time to hold before answering empty.
|
|
109
|
+
* @param signal - Aborts the hold when the browser goes away.
|
|
110
|
+
* @returns the queued operations, oldest first.
|
|
111
|
+
*/
|
|
112
|
+
async poll(sessionId, holdMs, signal) {
|
|
113
|
+
const state = this.stateOf(sessionId);
|
|
114
|
+
state.lastPollAt = Date.now();
|
|
115
|
+
if (state.queued.length > 0)
|
|
116
|
+
return state.queued.splice(0, state.queued.length);
|
|
117
|
+
if (holdMs <= 0 || signal?.aborted === true)
|
|
118
|
+
return [];
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
const finish = () => {
|
|
122
|
+
if (settled)
|
|
123
|
+
return;
|
|
124
|
+
settled = true;
|
|
125
|
+
state.waiters.delete(onWake);
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
signal?.removeEventListener('abort', onWake);
|
|
128
|
+
resolve();
|
|
129
|
+
};
|
|
130
|
+
const onWake = () => { finish(); };
|
|
131
|
+
const timer = setTimeout(finish, holdMs);
|
|
132
|
+
state.waiters.add(onWake);
|
|
133
|
+
signal?.addEventListener('abort', onWake, { once: true });
|
|
134
|
+
});
|
|
135
|
+
return state.queued.splice(0, state.queued.length);
|
|
136
|
+
}
|
|
137
|
+
/** Record a surface-reported result and wake whoever is waiting for it. */
|
|
138
|
+
settle(sessionId, result) {
|
|
139
|
+
const state = this.sessions.get(sessionId);
|
|
140
|
+
if (state === undefined)
|
|
141
|
+
return false;
|
|
142
|
+
state.settled.set(result.id, result);
|
|
143
|
+
this.wake(state);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Wait for one operation's result.
|
|
148
|
+
*
|
|
149
|
+
* @param sessionId - Session that owns the operation.
|
|
150
|
+
* @param operationId - Operation to await.
|
|
151
|
+
* @param timeoutMs - Bound on the wait; a timeout is a real failure the caller reports.
|
|
152
|
+
* @param signal - Aborts the wait when the Agent turn is cancelled.
|
|
153
|
+
* @returns the result, or `undefined` when the surface never reported.
|
|
154
|
+
*/
|
|
155
|
+
async awaitResult(sessionId, operationId, timeoutMs, signal) {
|
|
156
|
+
const state = this.stateOf(sessionId);
|
|
157
|
+
const immediate = state.settled.get(operationId);
|
|
158
|
+
if (immediate !== undefined) {
|
|
159
|
+
state.settled.delete(operationId);
|
|
160
|
+
return immediate;
|
|
161
|
+
}
|
|
162
|
+
if (signal?.aborted === true)
|
|
163
|
+
return undefined;
|
|
164
|
+
return await new Promise((resolve) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
const finish = (result) => {
|
|
167
|
+
if (settled)
|
|
168
|
+
return;
|
|
169
|
+
settled = true;
|
|
170
|
+
state.waiters.delete(onWake);
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
signal?.removeEventListener('abort', onWake);
|
|
173
|
+
resolve(result);
|
|
174
|
+
};
|
|
175
|
+
const onWake = () => {
|
|
176
|
+
if (signal?.aborted === true || state.released) {
|
|
177
|
+
finish(undefined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const result = state.settled.get(operationId);
|
|
181
|
+
if (result === undefined)
|
|
182
|
+
return;
|
|
183
|
+
state.settled.delete(operationId);
|
|
184
|
+
finish(result);
|
|
185
|
+
};
|
|
186
|
+
const timer = setTimeout(() => { finish(undefined); }, timeoutMs);
|
|
187
|
+
state.waiters.add(onWake);
|
|
188
|
+
signal?.addEventListener('abort', onWake, { once: true });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** Forget one Session's whole bridge state (Session teardown). */
|
|
192
|
+
forget(sessionId) {
|
|
193
|
+
const state = this.sessions.get(sessionId);
|
|
194
|
+
if (state === undefined)
|
|
195
|
+
return;
|
|
196
|
+
// Mark before waking: a waiter must see the release and stop waiting, not
|
|
197
|
+
// look up a result that will never arrive.
|
|
198
|
+
state.released = true;
|
|
199
|
+
this.sessions.delete(sessionId);
|
|
200
|
+
this.wake(state);
|
|
201
|
+
}
|
|
202
|
+
/** Every Session currently holding bridge state; used by teardown sweeps. */
|
|
203
|
+
sessions_() {
|
|
204
|
+
return [...this.sessions.keys()];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node half of dsh-surface-bridge.
|
|
3
|
+
*
|
|
4
|
+
* The bridge exists so a right-Sidebar business surface can hand a selection to
|
|
5
|
+
* the composer once, and have it arrive in the model step as real context. This
|
|
6
|
+
* half owns the two things that must be unique per process: the Session-keyed
|
|
7
|
+
* selection/operation state, and the `agent/pre-step` listener that turns a read
|
|
8
|
+
* selection into **two** durable messages — one visible row the person can see (a
|
|
9
|
+
* file chip for the drawing, plus a one-line summary) and one hidden row carrying
|
|
10
|
+
* the element table the model works from.
|
|
11
|
+
*
|
|
12
|
+
* Why a mounted bundle rather than a shared library: a library would be inlined
|
|
13
|
+
* into each consuming plugin's own bundle, giving every consumer its own service
|
|
14
|
+
* instance and its own composer chip. The uniqueness the seam needs — one chip,
|
|
15
|
+
* one injection point — can only be guaranteed by the Loader mounting one bundle.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-surface-bridge
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
20
|
+
import { createUserMessage, type ContextFormed } from '@deepseek-ai/dsh-llm';
|
|
21
|
+
import type { SurfaceBridgeHostFace, SurfaceBridgeService, SurfaceSelection } from './contract.ts';
|
|
22
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
23
|
+
interface MessageSourceMap {
|
|
24
|
+
/**
|
|
25
|
+
* The hidden half of one surface selection: the element table the model reads.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately a producer kind, because that is what makes it *invisible*: the Chat
|
|
28
|
+
* view projects any source whose kind is not `user` into a `context` node, and renders
|
|
29
|
+
* no row for those. The visible half is a plain user message; see
|
|
30
|
+
* {@link buildSelectionMessages}.
|
|
31
|
+
*/
|
|
32
|
+
'surface-selection': {
|
|
33
|
+
kind: 'surface-selection';
|
|
34
|
+
} & ContextFormed;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The whole shared vocabulary, re-exported from the entry consumers already use.
|
|
39
|
+
*
|
|
40
|
+
* A surface plugin calls `ctx.surfaceBridge`/`ctx.surfaceBridgeHost` at runtime and
|
|
41
|
+
* never imports either half, but it must be able to *name* the faces and the
|
|
42
|
+
* descriptor it registers. Type-only, so neither half gains a runtime dependency
|
|
43
|
+
* on the other — and this module is also what carries the `Context` augmentation
|
|
44
|
+
* below, which is why a consumer should import the bridge by its package name
|
|
45
|
+
* rather than reaching for `./contract` alone.
|
|
46
|
+
*/
|
|
47
|
+
export type * from './contract.ts';
|
|
48
|
+
/**
|
|
49
|
+
* The two services this bundle provides.
|
|
50
|
+
*
|
|
51
|
+
* This block MUST stay in the package entry module. TypeScript merges an
|
|
52
|
+
* `interface Context` augmentation into the cordis class only when the augmenting
|
|
53
|
+
* file is reached as a program root through a non-empty import; placed in
|
|
54
|
+
* `contract.ts` (or any module that also carries the shared types) the same block
|
|
55
|
+
* degrades into an *ambient* declaration that REPLACES cordis's `Context`, and
|
|
56
|
+
* every `ctx.effect`/`ctx.on` in the graph stops type-checking. The build guard in
|
|
57
|
+
* `tests/context-augmentation.test.mjs` fails if it moves.
|
|
58
|
+
*/
|
|
59
|
+
declare module '@deepseek-ai/cordis' {
|
|
60
|
+
interface Context {
|
|
61
|
+
/** Browser face of the surface bridge; the seam a business surface publishes into. */
|
|
62
|
+
surfaceBridge: SurfaceBridgeService;
|
|
63
|
+
/** Host face of the surface bridge; the seam a business plugin's tools call. */
|
|
64
|
+
surfaceBridgeHost: SurfaceBridgeHostFace;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
68
|
+
export declare const name = "dsh-surface-bridge";
|
|
69
|
+
/** Route registration and image admission are the only Host capabilities this half needs. */
|
|
70
|
+
export declare const inject: readonly ["connection", "attachments"];
|
|
71
|
+
/**
|
|
72
|
+
* Build the two durable messages one selection travels in.
|
|
73
|
+
*
|
|
74
|
+
* **Two messages, one visible.** The person who sent a selection must be able to see what
|
|
75
|
+
* went out; the model needs the whole element table. Those are different appetites, and one
|
|
76
|
+
* message cannot satisfy both:
|
|
77
|
+
*
|
|
78
|
+
* · the **visible** row is a plain user message carrying one line — `画布选区 · main.excalidraw ·
|
|
79
|
+
* 3 个元素` — because the Chat view renders no row at all for a producer-tagged context node
|
|
80
|
+
* (`isVisibleChatNode` excludes ordinary Context, keeping only tool changes). Before this,
|
|
81
|
+
* the selection reached the model and appeared nowhere in the transcript.
|
|
82
|
+
* · the **detail** row is producer-tagged, and therefore hidden: it carries the element ids,
|
|
83
|
+
* coordinates and sizes the model edits from, plus the file path the write-back must name.
|
|
84
|
+
*
|
|
85
|
+
* Images ride in the visible row, so a person sees the pictures they sent. Bytes never enter
|
|
86
|
+
* the text, and a surface with no image element pays for no image.
|
|
87
|
+
*
|
|
88
|
+
* @param ctx - Plugin context carrying the attachment service.
|
|
89
|
+
* @param selection - Selection to render.
|
|
90
|
+
* @returns the messages to append to the step, visible row first.
|
|
91
|
+
*/
|
|
92
|
+
export declare function buildSelectionMessages(ctx: Context, selection: SurfaceSelection): Promise<readonly ReturnType<typeof createUserMessage>[]>;
|
|
93
|
+
export declare function apply(ctx: Context): void;
|