@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.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/README.i18n.yaml +2 -2
- package/README.md +56 -26
- package/README.zh.md +20 -15
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +64 -0
- package/lib/client.js +272 -47
- package/lib/client.js.map +1 -1
- package/lib/index.js +316 -60
- package/lib/mobile.js +487 -56
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +13 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +36 -3
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +1 -1
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +17 -4
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +30 -13
- package/lib/types/mobile/PairRequiredView.d.ts +7 -0
- package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.js +28 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.test.js +24 -0
- package/lib/types/mobile/api.d.ts +10 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +4 -0
- package/lib/types/mobile/index.js +14 -1
- package/lib/types/mobile/messages.d.ts +24 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +60 -23
- package/lib/types/mobile/messages.test.js +49 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +86 -0
- package/lib/types/mobile/pairing.d.ts +31 -0
- package/lib/types/mobile/pairing.d.ts.map +1 -0
- package/lib/types/mobile/pairing.js +62 -0
- package/lib/types/mobile/views/App.d.ts +12 -5
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +34 -7
- package/lib/types/mobile/views/App.test.d.ts +2 -0
- package/lib/types/mobile/views/App.test.d.ts.map +1 -0
- package/lib/types/mobile/views/App.test.js +52 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +34 -11
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +30 -4
- package/lib/types/mobile/views/SessionListView.test.js +58 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +4 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +10 -1
- package/lib/types/mobile-pwa.d.ts +6 -0
- package/lib/types/mobile-pwa.d.ts.map +1 -0
- package/lib/types/mobile-pwa.js +17 -0
- package/lib/types/mobile-routes.d.ts +5 -8
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +115 -30
- package/lib/types/pairing-access.d.ts +28 -0
- package/lib/types/pairing-access.d.ts.map +1 -0
- package/lib/types/pairing-access.js +26 -0
- package/lib/types/pairing.d.ts +27 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +68 -0
- package/lib/types/pairing.persist.test.d.ts +2 -0
- package/lib/types/pairing.persist.test.d.ts.map +1 -0
- package/lib/types/pairing.persist.test.js +128 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +23 -3
- package/lib/types/tunnel.d.ts +1 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -2
- package/lib/types/update.d.ts +4 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +35 -12
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/RemoteEntry.tsx +12 -2
- package/src/client/RemoteSettingsCard.tsx +3 -2
- package/src/client/UpdateEntry.tsx +36 -4
- package/src/client/UpdatePanel.tsx +2 -0
- package/src/client/deep-link.ts +1 -1
- package/src/client/locales.ts +4 -0
- package/src/client/remote.module.css +26 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/gate.ts +17 -3
- package/src/index.ts +46 -15
- package/src/mobile/PairRequiredView.test.tsx +28 -0
- package/src/mobile/PairRequiredView.tsx +58 -0
- package/src/mobile/api.ts +14 -1
- package/src/mobile/index.tsx +15 -1
- package/src/mobile/messages.test.ts +52 -1
- package/src/mobile/messages.ts +65 -22
- package/src/mobile/mobile-styles.ts +86 -0
- package/src/mobile/pairing.ts +77 -0
- package/src/mobile/views/App.test.tsx +62 -0
- package/src/mobile/views/App.tsx +53 -6
- package/src/mobile/views/ChatView.tsx +33 -10
- package/src/mobile/views/SessionListView.test.tsx +69 -1
- package/src/mobile/views/SessionListView.tsx +58 -5
- package/src/mobile/views/WorkspaceView.tsx +4 -2
- package/src/mobile-api.ts +9 -1
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +123 -30
- package/src/pairing-access.ts +36 -0
- package/src/pairing.persist.test.ts +145 -0
- package/src/pairing.ts +70 -1
- package/src/routes.ts +23 -3
- package/src/tunnel.ts +7 -2
- package/src/update.ts +42 -14
package/lib/types/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { IncomingMessage } from 'node:http';
|
|
11
11
|
import type { Context } from '@deepseek-ai/cordis';
|
|
12
12
|
import z from 'schemastery';
|
|
13
|
+
import { type PairingConfig } from './pairing.ts';
|
|
13
14
|
declare module '@deepseek-ai/cordis' {
|
|
14
15
|
interface Events {
|
|
15
16
|
/**
|
|
@@ -57,6 +58,14 @@ export interface Config {
|
|
|
57
58
|
* warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
|
|
58
59
|
*/
|
|
59
60
|
publicBaseUrl?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Absolute path to a JSON file where paired device sessions are persisted.
|
|
63
|
+
* When set, a paired phone keeps its session across `dsh web` restarts
|
|
64
|
+
* (the phone-side cookie already lives 365 days), so re-scanning the QR
|
|
65
|
+
* after each restart is unnecessary. Unset keeps sessions memory-only
|
|
66
|
+
* (previous behavior).
|
|
67
|
+
*/
|
|
68
|
+
devicesFile?: string;
|
|
60
69
|
/**
|
|
61
70
|
* When true, the plugin runs its own Cloudflare quick tunnel (the
|
|
62
71
|
* cloudflared binary ships with the package — no user-side install) and
|
|
@@ -76,6 +85,22 @@ export interface Config {
|
|
|
76
85
|
enabled?: boolean;
|
|
77
86
|
}
|
|
78
87
|
export declare const Config: z<Config>;
|
|
88
|
+
/**
|
|
89
|
+
* Fully resolved config: every field non-optional except `publicBaseUrl`,
|
|
90
|
+
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
91
|
+
* optional, so `Required` alone would over-narrow it to `string`).
|
|
92
|
+
*/
|
|
93
|
+
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
|
|
94
|
+
publicBaseUrl: string | undefined;
|
|
95
|
+
devicesFile: string | undefined;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* The single mapping from resolved plugin config to the pairing service
|
|
99
|
+
* config. Both the constructed service and every live settings sync reuse
|
|
100
|
+
* it, so no field can be silently dropped when the web settings surface
|
|
101
|
+
* pushes a new value into the running service.
|
|
102
|
+
*/
|
|
103
|
+
export declare function pairingConfigOf(resolved: Pick<ResolvedConfig, 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'>): PairingConfig;
|
|
79
104
|
/**
|
|
80
105
|
* Mount the pairing service, routes, gate listener, and presence sweep.
|
|
81
106
|
* @param ctx - host plugin context carrying webServer.
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAE3B,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AAmBjE,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAA4B,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAW3B,CAAA;AAKF;;;;GAIG;AACH,KAAK,cAAc,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,GAAG;IAC9E,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;CAChC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,IAAI,CAC5C,cAAc,EACd,YAAY,GAAG,gBAAgB,GAAG,YAAY,GAAG,YAAY,GAAG,aAAa,CAC9E,GAAG,aAAa,CAQhB;AAgBD;;;;GAIG;AACH,eAAO,MAAM,KAAK,kBAAuD,CAAA;AAEzE,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAgOtD"}
|
package/lib/types/index.js
CHANGED
|
@@ -12,7 +12,8 @@ import { setInterval as nodeSetInterval } from 'node:timers';
|
|
|
12
12
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
13
13
|
import z from 'schemastery';
|
|
14
14
|
import { PairingService } from './pairing.js';
|
|
15
|
-
import { makeGateListener } from './gate.js';
|
|
15
|
+
import { isPairedDeviceRequest, makeGateListener } from './gate.js';
|
|
16
|
+
import { RemoteWebUiPairing } from './pairing-access.js';
|
|
16
17
|
import { isTrustedApiRequest, makeRoutes } from './routes.js';
|
|
17
18
|
import { makeMobileRoutes } from './mobile-routes.js';
|
|
18
19
|
import { makeMobileApiRoutes } from './mobile-api.js';
|
|
@@ -38,12 +39,28 @@ export const Config = z.object({
|
|
|
38
39
|
cookieName: z.string().min(1).default('dsh_pair'),
|
|
39
40
|
requirePairingForLan: z.boolean().default(true),
|
|
40
41
|
publicBaseUrl: z.string(),
|
|
42
|
+
devicesFile: z.string(),
|
|
41
43
|
autoTunnel: z.boolean().default(false),
|
|
42
44
|
mobileEnterToSend: z.boolean().default(true),
|
|
43
45
|
enabled: z.boolean().default(true),
|
|
44
46
|
});
|
|
45
47
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
46
48
|
const SWEEP_INTERVAL_MS = 10_000;
|
|
49
|
+
/**
|
|
50
|
+
* The single mapping from resolved plugin config to the pairing service
|
|
51
|
+
* config. Both the constructed service and every live settings sync reuse
|
|
52
|
+
* it, so no field can be silently dropped when the web settings surface
|
|
53
|
+
* pushes a new value into the running service.
|
|
54
|
+
*/
|
|
55
|
+
export function pairingConfigOf(resolved) {
|
|
56
|
+
return {
|
|
57
|
+
tokenTtlMs: resolved.tokenTtlMs,
|
|
58
|
+
offlineAfterMs: resolved.offlineAfterMs,
|
|
59
|
+
maxDevices: resolved.maxDevices,
|
|
60
|
+
cookieName: resolved.cookieName,
|
|
61
|
+
devicesFile: resolved.devicesFile,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
47
64
|
/** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
|
|
48
65
|
const DEFAULTS = {
|
|
49
66
|
tokenTtlMs: 10 * 60_000,
|
|
@@ -52,6 +69,7 @@ const DEFAULTS = {
|
|
|
52
69
|
cookieName: 'dsh_pair',
|
|
53
70
|
requirePairingForLan: true,
|
|
54
71
|
publicBaseUrl: undefined,
|
|
72
|
+
devicesFile: undefined,
|
|
55
73
|
autoTunnel: false,
|
|
56
74
|
mobileEnterToSend: true,
|
|
57
75
|
enabled: true,
|
|
@@ -70,6 +88,7 @@ function applyImpl(ctx, config) {
|
|
|
70
88
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
71
89
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
72
90
|
publicBaseUrl: config?.publicBaseUrl,
|
|
91
|
+
devicesFile: config?.devicesFile,
|
|
73
92
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
74
93
|
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
75
94
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
@@ -87,17 +106,13 @@ function applyImpl(ctx, config) {
|
|
|
87
106
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
88
107
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
89
108
|
publicBaseUrl: value.publicBaseUrl,
|
|
109
|
+
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
90
110
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
91
111
|
mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
92
112
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
93
113
|
};
|
|
94
114
|
};
|
|
95
|
-
const service = new PairingService(
|
|
96
|
-
tokenTtlMs: resolved.tokenTtlMs,
|
|
97
|
-
offlineAfterMs: resolved.offlineAfterMs,
|
|
98
|
-
maxDevices: resolved.maxDevices,
|
|
99
|
-
cookieName: resolved.cookieName,
|
|
100
|
-
});
|
|
115
|
+
const service = new PairingService(pairingConfigOf(resolved));
|
|
101
116
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
102
117
|
// The minted public URL becomes the QR base (and the pairing fence's
|
|
103
118
|
// trusted host). Phone /api traffic rides the plugin's own /m/api channel,
|
|
@@ -228,14 +243,16 @@ function applyImpl(ctx, config) {
|
|
|
228
243
|
];
|
|
229
244
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
230
245
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate');
|
|
246
|
+
// Sibling plugins (aionui-panel, …) look this up by name. Absent when this
|
|
247
|
+
// plugin is not installed; stop() / enabled=false still refuse cookies.
|
|
248
|
+
new RemoteWebUiPairing(ctx, (request) => {
|
|
249
|
+
if (!resolve().enabled)
|
|
250
|
+
return false;
|
|
251
|
+
return isPairedDeviceRequest(service, request);
|
|
252
|
+
});
|
|
231
253
|
const sync = () => {
|
|
232
254
|
const value = resolve();
|
|
233
|
-
service.config =
|
|
234
|
-
tokenTtlMs: value.tokenTtlMs,
|
|
235
|
-
offlineAfterMs: value.offlineAfterMs,
|
|
236
|
-
maxDevices: value.maxDevices,
|
|
237
|
-
cookieName: value.cookieName,
|
|
238
|
-
};
|
|
255
|
+
service.config = pairingConfigOf(value);
|
|
239
256
|
// The auto tunnel owns the public base while enabled: the minted URL
|
|
240
257
|
// lands in the service through the tunnel's phase listener. The manual
|
|
241
258
|
// publicBaseUrl applies only when the auto tunnel is off.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface PairRequiredViewProps {
|
|
2
|
+
initialError?: string;
|
|
3
|
+
onPaired(path: string): void;
|
|
4
|
+
}
|
|
5
|
+
/** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
|
|
6
|
+
export declare function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps): import("react").JSX.Element;
|
|
7
|
+
//# sourceMappingURL=PairRequiredView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PairRequiredView.d.ts","sourceRoot":"","sources":["../../../src/mobile/PairRequiredView.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,qBAAqB;IACpC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAED,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,qBAAqB,+BA+CjF"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.js';
|
|
5
|
+
/** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
|
|
6
|
+
export function PairRequiredView({ initialError, onPaired }) {
|
|
7
|
+
const [value, setValue] = useState('');
|
|
8
|
+
const [error, setError] = useState(initialError);
|
|
9
|
+
const [submitting, setSubmitting] = useState(false);
|
|
10
|
+
const submit = async (event) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
const input = parseMobilePairInput(value);
|
|
13
|
+
if (input === undefined) {
|
|
14
|
+
setError('请输入有效的配对链接。');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
setSubmitting(true);
|
|
18
|
+
setError(undefined);
|
|
19
|
+
const result = await acceptMobilePair(input.token);
|
|
20
|
+
setSubmitting(false);
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
setError(result.message);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
onPaired(mobilePairPath(input.workspaceId));
|
|
26
|
+
};
|
|
27
|
+
return (_jsx("main", { className: "mobile mobile-pair", "aria-labelledby": "mobile-pair-title", children: _jsxs("form", { className: "mobile-pairCard", onSubmit: (event) => { void submit(event); }, children: [_jsx("h1", { id: "mobile-pair-title", className: "mobile-title", children: "\u8BBE\u5907\u914D\u5BF9" }), _jsx("p", { className: "mobile-muted", children: "\u7C98\u8D34\u684C\u9762\u7AEF\u590D\u5236\u7684\u914D\u5BF9\u94FE\u63A5\u4EE5\u8FDE\u63A5\u6B64\u8BBE\u5907\u3002" }), _jsx("label", { className: "mobile-pairLabel", htmlFor: "mobile-pair-link", children: "\u914D\u5BF9\u94FE\u63A5" }), _jsx("input", { id: "mobile-pair-link", className: "mobile-pairInput", value: value, onChange: (event) => { setValue(event.target.value); }, autoComplete: "off", autoCapitalize: "off", spellCheck: false }), error === undefined ? null : _jsx("p", { className: "mobile-error", role: "alert", children: error }), _jsx("button", { className: "mobile-new mobile-pairSubmit", type: "submit", disabled: submitting, children: submitting ? '正在配对' : '配对' })] }) }));
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PairRequiredView.test.d.ts","sourceRoot":"","sources":["../../../src/mobile/PairRequiredView.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { PairRequiredView } from './PairRequiredView.js';
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanup();
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
});
|
|
10
|
+
describe('PairRequiredView', () => {
|
|
11
|
+
it('accepts a pasted link in the installed app context', async () => {
|
|
12
|
+
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
13
|
+
vi.stubGlobal('fetch', fetch);
|
|
14
|
+
const onPaired = vi.fn();
|
|
15
|
+
render(_jsx(PairRequiredView, { onPaired: onPaired }));
|
|
16
|
+
fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } });
|
|
17
|
+
fireEvent.click(screen.getByRole('button', { name: '配对' }));
|
|
18
|
+
await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'));
|
|
19
|
+
});
|
|
20
|
+
it('shows an initial QR failure without starting the mobile data channel', () => {
|
|
21
|
+
render(_jsx(PairRequiredView, { initialError: "\u914D\u5BF9\u94FE\u63A5\u5DF2\u88AB\u4F7F\u7528\u3002", onPaired: vi.fn() }));
|
|
22
|
+
expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* rpc/mux layers).
|
|
6
6
|
*/
|
|
7
7
|
import type { WorkspaceView } from '@deepseek-ai/dsh-host-apiproxy/api/workspace';
|
|
8
|
+
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets';
|
|
8
9
|
import type { SessionSummary, SessionModels, SessionProjectionsBlock } from '@deepseek-ai/dsh-host-apiproxy/api/sessions';
|
|
9
10
|
/** One session.list page. */
|
|
10
11
|
export interface SessionPage {
|
|
@@ -19,6 +20,12 @@ export interface CreatedSession {
|
|
|
19
20
|
/** The composition the new session runs (echoed so the caller can label it). */
|
|
20
21
|
agentPreset?: string;
|
|
21
22
|
}
|
|
23
|
+
/** Agent presets available when composing a new session. */
|
|
24
|
+
export interface AgentPresetRoster {
|
|
25
|
+
presets: readonly AgentPresetEntry[];
|
|
26
|
+
authorable: boolean;
|
|
27
|
+
hasDocument: boolean;
|
|
28
|
+
}
|
|
22
29
|
/** One history page (already bounded to whole messages by the host). */
|
|
23
30
|
export interface HistoryPage {
|
|
24
31
|
events: import('@deepseek-ai/dsh-host-apiproxy/api/sessions').HistoryEntry[];
|
|
@@ -40,6 +47,8 @@ export declare function listWorkspaces(): Promise<WorkspaceView[]>;
|
|
|
40
47
|
export declare function fetchMobilePreferences(): Promise<MobilePreferences>;
|
|
41
48
|
/** One session.list page; omit the cursor for the first page. */
|
|
42
49
|
export declare function listSessions(cursor?: string): Promise<SessionPage>;
|
|
50
|
+
/** Read the available agent compositions for a new session. */
|
|
51
|
+
export declare function listAgentPresets(): Promise<AgentPresetRoster>;
|
|
43
52
|
/**
|
|
44
53
|
* Create a blank session (entity birth precedes the first message). Name a
|
|
45
54
|
* workspace to attach it there, or a cwd; omitting both uses the host cwd.
|
|
@@ -47,6 +56,7 @@ export declare function listSessions(cursor?: string): Promise<SessionPage>;
|
|
|
47
56
|
export declare function createSession(options?: {
|
|
48
57
|
workspaceId?: string;
|
|
49
58
|
cwd?: string;
|
|
59
|
+
agentPreset?: string;
|
|
50
60
|
}): Promise<CreatedSession>;
|
|
51
61
|
/** One history window; omit beforeSeq for the tail page, pass a signal to abort. */
|
|
52
62
|
export declare function history(sessionId: string, beforeSeq?: number, maxMessages?: number, signal?: AbortSignal): Promise<HistoryPage>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/mobile/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAA;AACjF,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6CAA6C,CAAA;AAGzH,6BAA6B;AAC7B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,6CAA6C,EAAE,YAAY,EAAE,CAAA;IAC5E,OAAO,EAAE,OAAO,CAAA;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,uBAAuB,CAAA;CACtC;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,iBAAiB,EAAE,OAAO,CAAA;CAC3B;AAED,kEAAkE;AAClE,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAG/D;AAED,yFAAyF;AACzF,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEzE;AAED,iEAAiE;AACjE,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAExE;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/mobile/api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAA;AACjF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kDAAkD,CAAA;AACxF,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6CAA6C,CAAA;AAGzH,6BAA6B;AAC7B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,4DAA4D;AAC5D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;IACpC,UAAU,EAAE,OAAO,CAAA;IACnB,WAAW,EAAE,OAAO,CAAA;CACrB;AAED,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,6CAA6C,EAAE,YAAY,EAAE,CAAA;IAC5E,OAAO,EAAE,OAAO,CAAA;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,uBAAuB,CAAA;CACtC;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,iBAAiB,EAAE,OAAO,CAAA;CAC3B;AAED,kEAAkE;AAClE,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAG/D;AAED,yFAAyF;AACzF,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEzE;AAED,iEAAiE;AACjE,wBAAsB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAExE;AAED,+DAA+D;AAC/D,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAEnE;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,GACzE,OAAO,CAAC,cAAc,CAAC,CAEzB;AAED,oFAAoF;AACpF,wBAAsB,OAAO,CAC3B,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,EAClB,WAAW,SAAK,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,WAAW,CAAC,CAMtB;AAED,qEAAqE;AACrE,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM3E;AAED,wEAAwE;AACxE,wBAAsB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMnF;AAED,oFAAoF;AACpF,wBAAsB,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAEtE;AAED,2FAA2F;AAC3F,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GACvE,OAAO,CAAC;IAAE,QAAQ,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,CAOtF"}
|
package/lib/types/mobile/api.js
CHANGED
|
@@ -18,6 +18,10 @@ export async function fetchMobilePreferences() {
|
|
|
18
18
|
export async function listSessions(cursor) {
|
|
19
19
|
return await callUnary('session.list', cursor === undefined ? {} : { cursor });
|
|
20
20
|
}
|
|
21
|
+
/** Read the available agent compositions for a new session. */
|
|
22
|
+
export async function listAgentPresets() {
|
|
23
|
+
return await callUnary('agentPreset.list', {});
|
|
24
|
+
}
|
|
21
25
|
/**
|
|
22
26
|
* Create a blank session (entity birth precedes the first message). Name a
|
|
23
27
|
* workspace to attach it there, or a cwd; omitting both uses the host cwd.
|
|
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client';
|
|
|
11
11
|
import { App } from './views/App.js';
|
|
12
12
|
import { mobileCss } from './mobile-styles.js';
|
|
13
13
|
import { initMobileTheme } from './mobile-theme.js';
|
|
14
|
+
import { registerMobilePwa } from '../mobile-pwa.js';
|
|
15
|
+
import { consumeMobilePairUrl } from './pairing.js';
|
|
14
16
|
// Apply the persisted (or default light) theme before first paint, so the
|
|
15
17
|
// page never flashes the wrong palette.
|
|
16
18
|
initMobileTheme();
|
|
@@ -22,4 +24,15 @@ document.head.appendChild(style);
|
|
|
22
24
|
const root = document.getElementById('root');
|
|
23
25
|
if (root === null)
|
|
24
26
|
throw new Error('mobile: #root missing');
|
|
25
|
-
|
|
27
|
+
void bootMobile(root);
|
|
28
|
+
async function bootMobile(mount) {
|
|
29
|
+
const pair = await consumeMobilePairUrl(window.location.href);
|
|
30
|
+
if (pair.kind === 'accepted') {
|
|
31
|
+
window.location.replace(pair.path);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (pair.kind === 'failed')
|
|
35
|
+
window.history.replaceState(null, '', pair.path);
|
|
36
|
+
void registerMobilePwa();
|
|
37
|
+
createRoot(mount).render(_jsx(App, { initialPairError: pair.kind === 'failed' ? pair.message : undefined }));
|
|
38
|
+
}
|
|
@@ -114,4 +114,28 @@ export interface WireEvent {
|
|
|
114
114
|
* @returns messages sorted by seq.
|
|
115
115
|
*/
|
|
116
116
|
export declare function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[];
|
|
117
|
+
/**
|
|
118
|
+
* Incremental folder for one message stream. Live chat folds one event at a
|
|
119
|
+
* time; rebuilding the five index maps by scanning every message per event
|
|
120
|
+
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
121
|
+
* indexes alive across folds, applies each event in O(1) map operations, and
|
|
122
|
+
* returns the previous snapshot identity unchanged when nothing applied, so
|
|
123
|
+
* React skips the re-render entirely. Replayed events are no-ops: the maxSeq
|
|
124
|
+
* watermark advanced by the first application skips them, which also makes a
|
|
125
|
+
* double-invoked React state updater harmless.
|
|
126
|
+
*/
|
|
127
|
+
export declare class EventFolder {
|
|
128
|
+
private state;
|
|
129
|
+
private snapshotList;
|
|
130
|
+
/** @param initial - seed rows (history tail load); omit for an empty stream. */
|
|
131
|
+
constructor(initial?: readonly RenderMessage[]);
|
|
132
|
+
/** Fold one batch incrementally; returns the current snapshot list. */
|
|
133
|
+
fold(events: readonly WireEvent[]): RenderMessage[];
|
|
134
|
+
/** Replace the whole stream (history reload / session switch). */
|
|
135
|
+
seed(messages: readonly RenderMessage[]): void;
|
|
136
|
+
/** Prepend an older history page (exact seam; no overlapping seqs). */
|
|
137
|
+
prepend(older: readonly RenderMessage[]): void;
|
|
138
|
+
/** Current snapshot list; a fresh copy whenever the folder changed. */
|
|
139
|
+
snapshot(): RenderMessage[];
|
|
140
|
+
}
|
|
117
141
|
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAweD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;IAChF,YAAY,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,EAE7C;IAED,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE,CAWlD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG7C;IAED,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG7C;IAED,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE,CAI1B;CACF"}
|
|
@@ -83,17 +83,6 @@ function chunkTarget(data) {
|
|
|
83
83
|
result.step = step;
|
|
84
84
|
return result;
|
|
85
85
|
}
|
|
86
|
-
/** Max message seq across an existing list, or -1 for an empty list. */
|
|
87
|
-
function watermarkOf(existing) {
|
|
88
|
-
if (existing === undefined)
|
|
89
|
-
return -1;
|
|
90
|
-
let maxSeq = -1;
|
|
91
|
-
for (const message of existing) {
|
|
92
|
-
if (message.seq > maxSeq)
|
|
93
|
-
maxSeq = message.seq;
|
|
94
|
-
}
|
|
95
|
-
return maxSeq;
|
|
96
|
-
}
|
|
97
86
|
function createState(existing) {
|
|
98
87
|
const messages = existing === undefined ? [] : [...existing];
|
|
99
88
|
const state = {
|
|
@@ -103,8 +92,11 @@ function createState(existing) {
|
|
|
103
92
|
turnStepMessage: new Map(),
|
|
104
93
|
messageTurn: new Map(),
|
|
105
94
|
toolNames: new Map(),
|
|
95
|
+
maxSeq: -1,
|
|
106
96
|
};
|
|
107
97
|
for (const message of messages) {
|
|
98
|
+
if (message.seq > state.maxSeq)
|
|
99
|
+
state.maxSeq = message.seq;
|
|
108
100
|
state.byId.set(message.id, message);
|
|
109
101
|
if (message.kind !== 'assistant')
|
|
110
102
|
continue;
|
|
@@ -167,6 +159,8 @@ function retargetTurnStep(state, key, oldMessage, next) {
|
|
|
167
159
|
}
|
|
168
160
|
/** Fold one event into the working state. Assumes the event passes the watermark. */
|
|
169
161
|
function applyEvent(state, event) {
|
|
162
|
+
if (event.seq > state.maxSeq)
|
|
163
|
+
state.maxSeq = event.seq;
|
|
170
164
|
switch (event.type) {
|
|
171
165
|
case 'user/message':
|
|
172
166
|
applyUserMessage(state, event);
|
|
@@ -489,18 +483,61 @@ function applyTurnEnd(state, event) {
|
|
|
489
483
|
* @returns messages sorted by seq.
|
|
490
484
|
*/
|
|
491
485
|
export function foldEvents(events, existing) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
486
|
+
return new EventFolder(existing).fold(events);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Incremental folder for one message stream. Live chat folds one event at a
|
|
490
|
+
* time; rebuilding the five index maps by scanning every message per event
|
|
491
|
+
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
492
|
+
* indexes alive across folds, applies each event in O(1) map operations, and
|
|
493
|
+
* returns the previous snapshot identity unchanged when nothing applied, so
|
|
494
|
+
* React skips the re-render entirely. Replayed events are no-ops: the maxSeq
|
|
495
|
+
* watermark advanced by the first application skips them, which also makes a
|
|
496
|
+
* double-invoked React state updater harmless.
|
|
497
|
+
*/
|
|
498
|
+
export class EventFolder {
|
|
499
|
+
state;
|
|
500
|
+
snapshotList;
|
|
501
|
+
/** @param initial - seed rows (history tail load); omit for an empty stream. */
|
|
502
|
+
constructor(initial) {
|
|
503
|
+
this.state = createState(initial);
|
|
499
504
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
505
|
+
/** Fold one batch incrementally; returns the current snapshot list. */
|
|
506
|
+
fold(events) {
|
|
507
|
+
const sorted = [...events].sort((a, b) => a.seq - b.seq);
|
|
508
|
+
let applied = false;
|
|
509
|
+
for (const event of sorted) {
|
|
510
|
+
if (event.seq <= this.state.maxSeq)
|
|
511
|
+
continue;
|
|
512
|
+
applyEvent(this.state, event);
|
|
513
|
+
applied = true;
|
|
514
|
+
}
|
|
515
|
+
if (!applied && this.snapshotList !== undefined)
|
|
516
|
+
return this.snapshotList;
|
|
517
|
+
this.snapshotList = snapshotOf(this.state);
|
|
518
|
+
return this.snapshotList;
|
|
519
|
+
}
|
|
520
|
+
/** Replace the whole stream (history reload / session switch). */
|
|
521
|
+
seed(messages) {
|
|
522
|
+
this.state = createState(messages);
|
|
523
|
+
this.snapshotList = undefined;
|
|
524
|
+
}
|
|
525
|
+
/** Prepend an older history page (exact seam; no overlapping seqs). */
|
|
526
|
+
prepend(older) {
|
|
527
|
+
this.state = createState([...older, ...this.state.messages]);
|
|
528
|
+
this.snapshotList = undefined;
|
|
529
|
+
}
|
|
530
|
+
/** Current snapshot list; a fresh copy whenever the folder changed. */
|
|
531
|
+
snapshot() {
|
|
532
|
+
if (this.snapshotList !== undefined)
|
|
533
|
+
return this.snapshotList;
|
|
534
|
+
this.snapshotList = snapshotOf(this.state);
|
|
535
|
+
return this.snapshotList;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
|
|
539
|
+
function snapshotOf(state) {
|
|
540
|
+
const out = [...state.messages];
|
|
504
541
|
let ordered = true;
|
|
505
542
|
for (let index = 1; index < out.length; index += 1) {
|
|
506
543
|
const prev = out[index - 1];
|
|
@@ -510,5 +547,5 @@ export function foldEvents(events, existing) {
|
|
|
510
547
|
break;
|
|
511
548
|
}
|
|
512
549
|
}
|
|
513
|
-
return ordered ? out :
|
|
550
|
+
return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1));
|
|
514
551
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** foldEvents: message-list folding from a session event stream. */
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { foldEvents } from './messages.js';
|
|
3
|
+
import { EventFolder, foldEvents } from './messages.js';
|
|
4
4
|
/** Assemble one event with an auto-incrementing seq / time. */
|
|
5
5
|
function makeEvent(type, data, seq, time = seq * 1_000) {
|
|
6
6
|
return { type, seq, time, data };
|
|
@@ -286,3 +286,51 @@ describe('foldEvents', () => {
|
|
|
286
286
|
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' });
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
describe('EventFolder incremental folding', () => {
|
|
290
|
+
it('matches a one-shot fold when events arrive one at a time', () => {
|
|
291
|
+
const stream = [
|
|
292
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
293
|
+
textChunk(0, 0, '你', 1),
|
|
294
|
+
textChunk(0, 0, '好', 2),
|
|
295
|
+
makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, '你好'), 3),
|
|
296
|
+
makeEvent('message/update', { id: 'u-1', text: '更新' }, 4),
|
|
297
|
+
];
|
|
298
|
+
const oneShot = foldEvents(stream);
|
|
299
|
+
const folder = new EventFolder();
|
|
300
|
+
let incremental = [];
|
|
301
|
+
for (const event of stream)
|
|
302
|
+
incremental = folder.fold([event]);
|
|
303
|
+
expect(incremental).toEqual(oneShot);
|
|
304
|
+
});
|
|
305
|
+
it('replays and re-folds are no-ops and reuse the previous snapshot identity', () => {
|
|
306
|
+
const folder = new EventFolder(foldEvents([
|
|
307
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
308
|
+
textChunk(0, 0, '你', 1),
|
|
309
|
+
]));
|
|
310
|
+
const first = folder.fold([textChunk(0, 0, '好', 2)]);
|
|
311
|
+
expect(first[first.length - 1]).toMatchObject({ text: '你好', pending: true });
|
|
312
|
+
// Same event again (wire replay or a double-invoked state updater): no change, same identity.
|
|
313
|
+
const replay = folder.fold([textChunk(0, 0, '好', 2)]);
|
|
314
|
+
expect(replay).toBe(first);
|
|
315
|
+
// A no-op batch over an already folded stream also keeps the identity.
|
|
316
|
+
expect(folder.fold([])).toBe(first);
|
|
317
|
+
});
|
|
318
|
+
it('prepends older pages and keeps folding live events on top', () => {
|
|
319
|
+
const folder = new EventFolder(foldEvents([
|
|
320
|
+
makeEvent('user/message', userMessageData('u-2', '第二页'), 10),
|
|
321
|
+
]));
|
|
322
|
+
folder.prepend(foldEvents([makeEvent('user/message', userMessageData('u-1', '第一页'), 5)]));
|
|
323
|
+
const withLive = folder.fold([textChunk(0, 0, '新', 11)]);
|
|
324
|
+
expect(withLive.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11']);
|
|
325
|
+
// The live fold must not lose the prepended rows on later events.
|
|
326
|
+
const later = folder.fold([textChunk(0, 0, '续', 12)]);
|
|
327
|
+
expect(later.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11']);
|
|
328
|
+
expect(later[2]).toMatchObject({ text: '新续', pending: true });
|
|
329
|
+
});
|
|
330
|
+
it('seed replaces the whole stream', () => {
|
|
331
|
+
const folder = new EventFolder(foldEvents([makeEvent('user/message', userMessageData('u-1', '旧'), 0)]));
|
|
332
|
+
folder.seed(foldEvents([makeEvent('user/message', userMessageData('u-2', '新'), 5)]));
|
|
333
|
+
expect(folder.snapshot().map(message => message.id)).toEqual(['u-2']);
|
|
334
|
+
expect(folder.fold([textChunk(0, 0, '追加', 6)]).map(message => message.id)).toEqual(['u-2', 'assistant,0.0#6']);
|
|
335
|
+
});
|
|
336
|
+
});
|