@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/src/index.ts
CHANGED
|
@@ -15,8 +15,9 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
15
15
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
16
|
import z from 'schemastery'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
-
import { PairingService } from './pairing.ts'
|
|
19
|
-
import { makeGateListener } from './gate.ts'
|
|
18
|
+
import { PairingService, type PairingConfig } from './pairing.ts'
|
|
19
|
+
import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
|
|
20
|
+
import { RemoteWebUiPairing } from './pairing-access.ts'
|
|
20
21
|
import { isTrustedApiRequest, makeRoutes } from './routes.ts'
|
|
21
22
|
import { makeMobileRoutes } from './mobile-routes.ts'
|
|
22
23
|
import { makeMobileApiRoutes } from './mobile-api.ts'
|
|
@@ -89,6 +90,14 @@ export interface Config {
|
|
|
89
90
|
* warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
|
|
90
91
|
*/
|
|
91
92
|
publicBaseUrl?: string
|
|
93
|
+
/**
|
|
94
|
+
* Absolute path to a JSON file where paired device sessions are persisted.
|
|
95
|
+
* When set, a paired phone keeps its session across `dsh web` restarts
|
|
96
|
+
* (the phone-side cookie already lives 365 days), so re-scanning the QR
|
|
97
|
+
* after each restart is unnecessary. Unset keeps sessions memory-only
|
|
98
|
+
* (previous behavior).
|
|
99
|
+
*/
|
|
100
|
+
devicesFile?: string
|
|
92
101
|
/**
|
|
93
102
|
* When true, the plugin runs its own Cloudflare quick tunnel (the
|
|
94
103
|
* cloudflared binary ships with the package — no user-side install) and
|
|
@@ -115,6 +124,7 @@ export const Config: z<Config> = z.object({
|
|
|
115
124
|
cookieName: z.string().min(1).default('dsh_pair'),
|
|
116
125
|
requirePairingForLan: z.boolean().default(true),
|
|
117
126
|
publicBaseUrl: z.string(),
|
|
127
|
+
devicesFile: z.string(),
|
|
118
128
|
autoTunnel: z.boolean().default(false),
|
|
119
129
|
mobileEnterToSend: z.boolean().default(true),
|
|
120
130
|
enabled: z.boolean().default(true),
|
|
@@ -128,7 +138,29 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
128
138
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
129
139
|
* optional, so `Required` alone would over-narrow it to `string`).
|
|
130
140
|
*/
|
|
131
|
-
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl'>> & {
|
|
141
|
+
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
|
|
142
|
+
publicBaseUrl: string | undefined
|
|
143
|
+
devicesFile: string | undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The single mapping from resolved plugin config to the pairing service
|
|
148
|
+
* config. Both the constructed service and every live settings sync reuse
|
|
149
|
+
* it, so no field can be silently dropped when the web settings surface
|
|
150
|
+
* pushes a new value into the running service.
|
|
151
|
+
*/
|
|
152
|
+
export function pairingConfigOf(resolved: Pick<
|
|
153
|
+
ResolvedConfig,
|
|
154
|
+
'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
|
|
155
|
+
>): PairingConfig {
|
|
156
|
+
return {
|
|
157
|
+
tokenTtlMs: resolved.tokenTtlMs,
|
|
158
|
+
offlineAfterMs: resolved.offlineAfterMs,
|
|
159
|
+
maxDevices: resolved.maxDevices,
|
|
160
|
+
cookieName: resolved.cookieName,
|
|
161
|
+
devicesFile: resolved.devicesFile,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
132
164
|
|
|
133
165
|
/** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
|
|
134
166
|
const DEFAULTS: ResolvedConfig = {
|
|
@@ -138,6 +170,7 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
138
170
|
cookieName: 'dsh_pair',
|
|
139
171
|
requirePairingForLan: true,
|
|
140
172
|
publicBaseUrl: undefined,
|
|
173
|
+
devicesFile: undefined,
|
|
141
174
|
autoTunnel: false,
|
|
142
175
|
mobileEnterToSend: true,
|
|
143
176
|
enabled: true,
|
|
@@ -158,6 +191,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
158
191
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
159
192
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
160
193
|
publicBaseUrl: config?.publicBaseUrl,
|
|
194
|
+
devicesFile: config?.devicesFile,
|
|
161
195
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
162
196
|
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
163
197
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
@@ -175,17 +209,13 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
175
209
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
176
210
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
177
211
|
publicBaseUrl: value.publicBaseUrl,
|
|
212
|
+
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
178
213
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
179
214
|
mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
180
215
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
181
216
|
}
|
|
182
217
|
}
|
|
183
|
-
const service = new PairingService(
|
|
184
|
-
tokenTtlMs: resolved.tokenTtlMs,
|
|
185
|
-
offlineAfterMs: resolved.offlineAfterMs,
|
|
186
|
-
maxDevices: resolved.maxDevices,
|
|
187
|
-
cookieName: resolved.cookieName,
|
|
188
|
-
})
|
|
218
|
+
const service = new PairingService(pairingConfigOf(resolved))
|
|
189
219
|
|
|
190
220
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
191
221
|
// The minted public URL becomes the QR base (and the pairing fence's
|
|
@@ -312,14 +342,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
312
342
|
]
|
|
313
343
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
314
344
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
|
|
345
|
+
// Sibling plugins (aionui-panel, …) look this up by name. Absent when this
|
|
346
|
+
// plugin is not installed; stop() / enabled=false still refuse cookies.
|
|
347
|
+
new RemoteWebUiPairing(ctx, (request) => {
|
|
348
|
+
if (!resolve().enabled) return false
|
|
349
|
+
return isPairedDeviceRequest(service, request)
|
|
350
|
+
})
|
|
315
351
|
const sync = (): void => {
|
|
316
352
|
const value = resolve()
|
|
317
|
-
service.config =
|
|
318
|
-
tokenTtlMs: value.tokenTtlMs,
|
|
319
|
-
offlineAfterMs: value.offlineAfterMs,
|
|
320
|
-
maxDevices: value.maxDevices,
|
|
321
|
-
cookieName: value.cookieName,
|
|
322
|
-
}
|
|
353
|
+
service.config = pairingConfigOf(value)
|
|
323
354
|
// The auto tunnel owns the public base while enabled: the minted URL
|
|
324
355
|
// lands in the service through the tunnel's phase listener. The manual
|
|
325
356
|
// publicBaseUrl applies only when the auto tunnel is off.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { PairRequiredView } from './PairRequiredView.tsx'
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanup()
|
|
8
|
+
vi.unstubAllGlobals()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('PairRequiredView', () => {
|
|
12
|
+
it('accepts a pasted link in the installed app context', async () => {
|
|
13
|
+
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
|
|
14
|
+
vi.stubGlobal('fetch', fetch)
|
|
15
|
+
const onPaired = vi.fn()
|
|
16
|
+
render(<PairRequiredView onPaired={onPaired} />)
|
|
17
|
+
|
|
18
|
+
fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } })
|
|
19
|
+
fireEvent.click(screen.getByRole('button', { name: '配对' }))
|
|
20
|
+
|
|
21
|
+
await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows an initial QR failure without starting the mobile data channel', () => {
|
|
25
|
+
render(<PairRequiredView initialError="配对链接已被使用。" onPaired={vi.fn()} />)
|
|
26
|
+
expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
|
|
2
|
+
import { type FormEvent, useState } from 'react'
|
|
3
|
+
import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.ts'
|
|
4
|
+
|
|
5
|
+
export interface PairRequiredViewProps {
|
|
6
|
+
initialError?: string
|
|
7
|
+
onPaired(path: string): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
|
|
11
|
+
export function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps) {
|
|
12
|
+
const [value, setValue] = useState('')
|
|
13
|
+
const [error, setError] = useState<string | undefined>(initialError)
|
|
14
|
+
const [submitting, setSubmitting] = useState(false)
|
|
15
|
+
|
|
16
|
+
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
17
|
+
event.preventDefault()
|
|
18
|
+
const input = parseMobilePairInput(value)
|
|
19
|
+
if (input === undefined) {
|
|
20
|
+
setError('请输入有效的配对链接。')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setSubmitting(true)
|
|
25
|
+
setError(undefined)
|
|
26
|
+
const result = await acceptMobilePair(input.token)
|
|
27
|
+
setSubmitting(false)
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
setError(result.message)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onPaired(mobilePairPath(input.workspaceId))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<main className="mobile mobile-pair" aria-labelledby="mobile-pair-title">
|
|
38
|
+
<form className="mobile-pairCard" onSubmit={(event) => { void submit(event) }}>
|
|
39
|
+
<h1 id="mobile-pair-title" className="mobile-title">设备配对</h1>
|
|
40
|
+
<p className="mobile-muted">粘贴桌面端复制的配对链接以连接此设备。</p>
|
|
41
|
+
<label className="mobile-pairLabel" htmlFor="mobile-pair-link">配对链接</label>
|
|
42
|
+
<input
|
|
43
|
+
id="mobile-pair-link"
|
|
44
|
+
className="mobile-pairInput"
|
|
45
|
+
value={value}
|
|
46
|
+
onChange={(event) => { setValue(event.target.value) }}
|
|
47
|
+
autoComplete="off"
|
|
48
|
+
autoCapitalize="off"
|
|
49
|
+
spellCheck={false}
|
|
50
|
+
/>
|
|
51
|
+
{error === undefined ? null : <p className="mobile-error" role="alert">{error}</p>}
|
|
52
|
+
<button className="mobile-new mobile-pairSubmit" type="submit" disabled={submitting}>
|
|
53
|
+
{submitting ? '正在配对' : '配对'}
|
|
54
|
+
</button>
|
|
55
|
+
</form>
|
|
56
|
+
</main>
|
|
57
|
+
)
|
|
58
|
+
}
|
package/src/mobile/api.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { WorkspaceView } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
9
|
+
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
|
|
9
10
|
import type { SessionSummary, SessionModels, SessionProjectionsBlock } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
10
11
|
import { callUnary } from './rpc.ts'
|
|
11
12
|
|
|
@@ -24,6 +25,13 @@ export interface CreatedSession {
|
|
|
24
25
|
agentPreset?: string
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Agent presets available when composing a new session. */
|
|
29
|
+
export interface AgentPresetRoster {
|
|
30
|
+
presets: readonly AgentPresetEntry[]
|
|
31
|
+
authorable: boolean
|
|
32
|
+
hasDocument: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/** One history page (already bounded to whole messages by the host). */
|
|
28
36
|
export interface HistoryPage {
|
|
29
37
|
events: import('@deepseek-ai/dsh-host-apiproxy/api/sessions').HistoryEntry[]
|
|
@@ -57,12 +65,17 @@ export async function listSessions(cursor?: string): Promise<SessionPage> {
|
|
|
57
65
|
return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** Read the available agent compositions for a new session. */
|
|
69
|
+
export async function listAgentPresets(): Promise<AgentPresetRoster> {
|
|
70
|
+
return await callUnary<AgentPresetRoster>('agentPreset.list', {})
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
/**
|
|
61
74
|
* Create a blank session (entity birth precedes the first message). Name a
|
|
62
75
|
* workspace to attach it there, or a cwd; omitting both uses the host cwd.
|
|
63
76
|
*/
|
|
64
77
|
export async function createSession(
|
|
65
|
-
options: { workspaceId?: string; cwd?: string } = {},
|
|
78
|
+
options: { workspaceId?: string; cwd?: string; agentPreset?: string } = {},
|
|
66
79
|
): Promise<CreatedSession> {
|
|
67
80
|
return await callUnary<CreatedSession>('session.create', options)
|
|
68
81
|
}
|
package/src/mobile/index.tsx
CHANGED
|
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client'
|
|
|
11
11
|
import { App } from './views/App.tsx'
|
|
12
12
|
import { mobileCss } from './mobile-styles.ts'
|
|
13
13
|
import { initMobileTheme } from './mobile-theme.ts'
|
|
14
|
+
import { registerMobilePwa } from '../mobile-pwa.ts'
|
|
15
|
+
import { consumeMobilePairUrl } from './pairing.ts'
|
|
14
16
|
|
|
15
17
|
// Apply the persisted (or default light) theme before first paint, so the
|
|
16
18
|
// page never flashes the wrong palette.
|
|
@@ -24,4 +26,16 @@ document.head.appendChild(style)
|
|
|
24
26
|
|
|
25
27
|
const root = document.getElementById('root')
|
|
26
28
|
if (root === null) throw new Error('mobile: #root missing')
|
|
27
|
-
|
|
29
|
+
void bootMobile(root)
|
|
30
|
+
|
|
31
|
+
async function bootMobile(mount: HTMLElement): Promise<void> {
|
|
32
|
+
const pair = await consumeMobilePairUrl(window.location.href)
|
|
33
|
+
if (pair.kind === 'accepted') {
|
|
34
|
+
window.location.replace(pair.path)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (pair.kind === 'failed') window.history.replaceState(null, '', pair.path)
|
|
38
|
+
|
|
39
|
+
void registerMobilePwa()
|
|
40
|
+
createRoot(mount).render(<App initialPairError={pair.kind === 'failed' ? pair.message : undefined} />)
|
|
41
|
+
}
|
|
@@ -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, type WireEvent } from './messages.ts'
|
|
3
|
+
import { EventFolder, foldEvents, type WireEvent } from './messages.ts'
|
|
4
4
|
|
|
5
5
|
/** Assemble one event with an auto-incrementing seq / time. */
|
|
6
6
|
function makeEvent(
|
|
@@ -326,3 +326,54 @@ describe('foldEvents', () => {
|
|
|
326
326
|
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
|
|
327
327
|
})
|
|
328
328
|
})
|
|
329
|
+
|
|
330
|
+
describe('EventFolder incremental folding', () => {
|
|
331
|
+
it('matches a one-shot fold when events arrive one at a time', () => {
|
|
332
|
+
const stream: WireEvent[] = [
|
|
333
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
334
|
+
textChunk(0, 0, '你', 1),
|
|
335
|
+
textChunk(0, 0, '好', 2),
|
|
336
|
+
makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, '你好'), 3),
|
|
337
|
+
makeEvent('message/update', { id: 'u-1', text: '更新' }, 4),
|
|
338
|
+
]
|
|
339
|
+
const oneShot = foldEvents(stream)
|
|
340
|
+
const folder = new EventFolder()
|
|
341
|
+
let incremental: ReturnType<typeof foldEvents> = []
|
|
342
|
+
for (const event of stream) incremental = folder.fold([event])
|
|
343
|
+
expect(incremental).toEqual(oneShot)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('replays and re-folds are no-ops and reuse the previous snapshot identity', () => {
|
|
347
|
+
const folder = new EventFolder(foldEvents([
|
|
348
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
349
|
+
textChunk(0, 0, '你', 1),
|
|
350
|
+
]))
|
|
351
|
+
const first = folder.fold([textChunk(0, 0, '好', 2)])
|
|
352
|
+
expect(first[first.length - 1]).toMatchObject({ text: '你好', pending: true })
|
|
353
|
+
// Same event again (wire replay or a double-invoked state updater): no change, same identity.
|
|
354
|
+
const replay = folder.fold([textChunk(0, 0, '好', 2)])
|
|
355
|
+
expect(replay).toBe(first)
|
|
356
|
+
// A no-op batch over an already folded stream also keeps the identity.
|
|
357
|
+
expect(folder.fold([])).toBe(first)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('prepends older pages and keeps folding live events on top', () => {
|
|
361
|
+
const folder = new EventFolder(foldEvents([
|
|
362
|
+
makeEvent('user/message', userMessageData('u-2', '第二页'), 10),
|
|
363
|
+
]))
|
|
364
|
+
folder.prepend(foldEvents([makeEvent('user/message', userMessageData('u-1', '第一页'), 5)]))
|
|
365
|
+
const withLive = folder.fold([textChunk(0, 0, '新', 11)])
|
|
366
|
+
expect(withLive.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
367
|
+
// The live fold must not lose the prepended rows on later events.
|
|
368
|
+
const later = folder.fold([textChunk(0, 0, '续', 12)])
|
|
369
|
+
expect(later.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
370
|
+
expect(later[2]).toMatchObject({ text: '新续', pending: true })
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('seed replaces the whole stream', () => {
|
|
374
|
+
const folder = new EventFolder(foldEvents([makeEvent('user/message', userMessageData('u-1', '旧'), 0)]))
|
|
375
|
+
folder.seed(foldEvents([makeEvent('user/message', userMessageData('u-2', '新'), 5)]))
|
|
376
|
+
expect(folder.snapshot().map(message => message.id)).toEqual(['u-2'])
|
|
377
|
+
expect(folder.fold([textChunk(0, 0, '追加', 6)]).map(message => message.id)).toEqual(['u-2', 'assistant,0.0#6'])
|
|
378
|
+
})
|
|
379
|
+
})
|
package/src/mobile/messages.ts
CHANGED
|
@@ -191,16 +191,6 @@ function chunkTarget(data: unknown): { text: string; kind: 'text' | 'reasoning';
|
|
|
191
191
|
return result
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/** Max message seq across an existing list, or -1 for an empty list. */
|
|
195
|
-
function watermarkOf(existing: readonly RenderMessage[] | undefined): number {
|
|
196
|
-
if (existing === undefined) return -1
|
|
197
|
-
let maxSeq = -1
|
|
198
|
-
for (const message of existing) {
|
|
199
|
-
if (message.seq > maxSeq) maxSeq = message.seq
|
|
200
|
-
}
|
|
201
|
-
return maxSeq
|
|
202
|
-
}
|
|
203
|
-
|
|
204
194
|
/** Mutable fold state; message objects are immutable and swapped on change. */
|
|
205
195
|
interface FoldState {
|
|
206
196
|
messages: RenderMessage[]
|
|
@@ -215,6 +205,8 @@ interface FoldState {
|
|
|
215
205
|
toolNames: Map<string, Set<string>>
|
|
216
206
|
/** Context window for the current model (from request/context). */
|
|
217
207
|
contextWindow?: number
|
|
208
|
+
/** Highest seq folded so far; the replay/watermark gate. */
|
|
209
|
+
maxSeq: number
|
|
218
210
|
}
|
|
219
211
|
|
|
220
212
|
function createState(existing: readonly RenderMessage[] | undefined): FoldState {
|
|
@@ -226,8 +218,10 @@ function createState(existing: readonly RenderMessage[] | undefined): FoldState
|
|
|
226
218
|
turnStepMessage: new Map(),
|
|
227
219
|
messageTurn: new Map(),
|
|
228
220
|
toolNames: new Map(),
|
|
221
|
+
maxSeq: -1,
|
|
229
222
|
}
|
|
230
223
|
for (const message of messages) {
|
|
224
|
+
if (message.seq > state.maxSeq) state.maxSeq = message.seq
|
|
231
225
|
state.byId.set(message.id, message)
|
|
232
226
|
if (message.kind !== 'assistant') continue
|
|
233
227
|
// Rebuild the (turn, step) and turn index maps lost when `existing` was
|
|
@@ -287,6 +281,7 @@ function retargetTurnStep(state: FoldState, key: string | undefined, oldMessage:
|
|
|
287
281
|
|
|
288
282
|
/** Fold one event into the working state. Assumes the event passes the watermark. */
|
|
289
283
|
function applyEvent(state: FoldState, event: WireEvent): void {
|
|
284
|
+
if (event.seq > state.maxSeq) state.maxSeq = event.seq
|
|
290
285
|
switch (event.type) {
|
|
291
286
|
case 'user/message':
|
|
292
287
|
applyUserMessage(state, event)
|
|
@@ -608,17 +603,65 @@ function applyTurnEnd(state: FoldState, event: WireEvent): void {
|
|
|
608
603
|
* @returns messages sorted by seq.
|
|
609
604
|
*/
|
|
610
605
|
export function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[] {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
606
|
+
return new EventFolder(existing).fold(events)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Incremental folder for one message stream. Live chat folds one event at a
|
|
611
|
+
* time; rebuilding the five index maps by scanning every message per event
|
|
612
|
+
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
613
|
+
* indexes alive across folds, applies each event in O(1) map operations, and
|
|
614
|
+
* returns the previous snapshot identity unchanged when nothing applied, so
|
|
615
|
+
* React skips the re-render entirely. Replayed events are no-ops: the maxSeq
|
|
616
|
+
* watermark advanced by the first application skips them, which also makes a
|
|
617
|
+
* double-invoked React state updater harmless.
|
|
618
|
+
*/
|
|
619
|
+
export class EventFolder {
|
|
620
|
+
private state: FoldState
|
|
621
|
+
private snapshotList: RenderMessage[] | undefined
|
|
622
|
+
|
|
623
|
+
/** @param initial - seed rows (history tail load); omit for an empty stream. */
|
|
624
|
+
constructor(initial?: readonly RenderMessage[]) {
|
|
625
|
+
this.state = createState(initial)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Fold one batch incrementally; returns the current snapshot list. */
|
|
629
|
+
fold(events: readonly WireEvent[]): RenderMessage[] {
|
|
630
|
+
const sorted = [...events].sort((a, b) => a.seq - b.seq)
|
|
631
|
+
let applied = false
|
|
632
|
+
for (const event of sorted) {
|
|
633
|
+
if (event.seq <= this.state.maxSeq) continue
|
|
634
|
+
applyEvent(this.state, event)
|
|
635
|
+
applied = true
|
|
636
|
+
}
|
|
637
|
+
if (!applied && this.snapshotList !== undefined) return this.snapshotList
|
|
638
|
+
this.snapshotList = snapshotOf(this.state)
|
|
639
|
+
return this.snapshotList
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Replace the whole stream (history reload / session switch). */
|
|
643
|
+
seed(messages: readonly RenderMessage[]): void {
|
|
644
|
+
this.state = createState(messages)
|
|
645
|
+
this.snapshotList = undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Prepend an older history page (exact seam; no overlapping seqs). */
|
|
649
|
+
prepend(older: readonly RenderMessage[]): void {
|
|
650
|
+
this.state = createState([...older, ...this.state.messages])
|
|
651
|
+
this.snapshotList = undefined
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Current snapshot list; a fresh copy whenever the folder changed. */
|
|
655
|
+
snapshot(): RenderMessage[] {
|
|
656
|
+
if (this.snapshotList !== undefined) return this.snapshotList
|
|
657
|
+
this.snapshotList = snapshotOf(this.state)
|
|
658
|
+
return this.snapshotList
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
|
|
663
|
+
function snapshotOf(state: FoldState): RenderMessage[] {
|
|
664
|
+
const out = [...state.messages]
|
|
622
665
|
let ordered = true
|
|
623
666
|
for (let index = 1; index < out.length; index += 1) {
|
|
624
667
|
const prev = out[index - 1]!
|
|
@@ -628,5 +671,5 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
|
|
|
628
671
|
break
|
|
629
672
|
}
|
|
630
673
|
}
|
|
631
|
-
return ordered ? out :
|
|
674
|
+
return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
|
|
632
675
|
}
|
|
@@ -252,6 +252,43 @@ body {
|
|
|
252
252
|
padding: 8px 16px;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
.mobile-create {
|
|
256
|
+
display: grid;
|
|
257
|
+
gap: 8px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.mobile-preset {
|
|
261
|
+
display: grid;
|
|
262
|
+
gap: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.mobile-presetLabel {
|
|
266
|
+
color: var(--m-text-secondary);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.mobile-presetSelect {
|
|
272
|
+
width: 100%;
|
|
273
|
+
height: 40px;
|
|
274
|
+
padding: 0 36px 0 12px;
|
|
275
|
+
border: 1px solid var(--m-border);
|
|
276
|
+
border-radius: 10px;
|
|
277
|
+
background: var(--m-bg-input);
|
|
278
|
+
color: var(--m-text);
|
|
279
|
+
font: inherit;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.mobile-presetSelect:disabled {
|
|
283
|
+
opacity: 0.6;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.mobile-presetDescription {
|
|
287
|
+
margin: 0;
|
|
288
|
+
color: var(--m-text-tertiary);
|
|
289
|
+
font-size: 12px;
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
/* ── buttons ─────────────────────────────────────────────────────────── */
|
|
256
293
|
|
|
257
294
|
.mobile-button {
|
|
@@ -896,6 +933,7 @@ body {
|
|
|
896
933
|
.mobile-row:focus-visible,
|
|
897
934
|
.mobile-button:focus-visible,
|
|
898
935
|
.mobile-new:focus-visible,
|
|
936
|
+
.mobile-presetSelect:focus-visible,
|
|
899
937
|
.chat-send:focus-visible,
|
|
900
938
|
.chat-chip:focus-visible,
|
|
901
939
|
.chat-msg-toggle:focus-visible,
|
|
@@ -1020,4 +1058,52 @@ body {
|
|
|
1020
1058
|
transition: none;
|
|
1021
1059
|
}
|
|
1022
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
/* ── installed-app pairing ───────────────────────────────────────────── */
|
|
1063
|
+
|
|
1064
|
+
.mobile-pair {
|
|
1065
|
+
min-height: 100dvh;
|
|
1066
|
+
display: grid;
|
|
1067
|
+
place-items: center;
|
|
1068
|
+
padding: 24px;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
.mobile-pairCard {
|
|
1072
|
+
display: grid;
|
|
1073
|
+
width: min(100%, 420px);
|
|
1074
|
+
gap: 12px;
|
|
1075
|
+
padding: 24px;
|
|
1076
|
+
border: 1px solid var(--m-border);
|
|
1077
|
+
border-radius: 12px;
|
|
1078
|
+
background: var(--m-bg-raised);
|
|
1079
|
+
box-shadow: var(--m-shadow-raise);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
.mobile-pairLabel {
|
|
1083
|
+
color: var(--m-text-secondary);
|
|
1084
|
+
font-size: 13px;
|
|
1085
|
+
font-weight: 600;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
.mobile-pairInput {
|
|
1089
|
+
width: 100%;
|
|
1090
|
+
min-height: 42px;
|
|
1091
|
+
box-sizing: border-box;
|
|
1092
|
+
padding: 9px 12px;
|
|
1093
|
+
border: 1px solid var(--m-border);
|
|
1094
|
+
border-radius: 8px;
|
|
1095
|
+
background: var(--m-bg-input);
|
|
1096
|
+
color: var(--m-text);
|
|
1097
|
+
font: inherit;
|
|
1098
|
+
outline: none;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.mobile-pairInput:focus-visible {
|
|
1102
|
+
border-color: var(--m-accent);
|
|
1103
|
+
box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.mobile-pairSubmit {
|
|
1107
|
+
width: 100%;
|
|
1108
|
+
}
|
|
1023
1109
|
`
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Pairing helpers owned by the standalone /m mobile surface. */
|
|
2
|
+
export type PairFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
3
|
+
|
|
4
|
+
export interface MobilePairInput {
|
|
5
|
+
token: string
|
|
6
|
+
workspaceId?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type MobilePairAccept =
|
|
10
|
+
| { ok: true }
|
|
11
|
+
| { ok: false; message: string }
|
|
12
|
+
|
|
13
|
+
export type MobilePairBootstrap =
|
|
14
|
+
| { kind: 'none' }
|
|
15
|
+
| { kind: 'accepted'; path: string }
|
|
16
|
+
| { kind: 'failed'; path: string; message: string }
|
|
17
|
+
|
|
18
|
+
const MOBILE_ROOT = '/m/'
|
|
19
|
+
|
|
20
|
+
/** Parse a pairing token or a copied pairing link without following its origin. */
|
|
21
|
+
export function parseMobilePairInput(value: string): MobilePairInput | undefined {
|
|
22
|
+
const trimmed = value.trim()
|
|
23
|
+
if (trimmed === '') return undefined
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(trimmed)
|
|
27
|
+
const token = url.searchParams.get('pair')
|
|
28
|
+
if (token === null || token === '') return undefined
|
|
29
|
+
const workspaceId = url.searchParams.get('workspace')
|
|
30
|
+
return {
|
|
31
|
+
token,
|
|
32
|
+
...(workspaceId !== null && workspaceId !== '' ? { workspaceId } : {}),
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return { token: trimmed }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the safe, token-free mobile destination after successful pairing. */
|
|
40
|
+
export function mobilePairPath(workspaceId?: string): string {
|
|
41
|
+
return workspaceId === undefined ? MOBILE_ROOT : MOBILE_ROOT + '?workspace=' + encodeURIComponent(workspaceId)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Accept one pairing token on this exact browser or installed-web-app context. */
|
|
45
|
+
export async function acceptMobilePair(token: string, fetcher: PairFetch = fetch): Promise<MobilePairAccept> {
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetcher('/api/pair/accept', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ token }),
|
|
51
|
+
})
|
|
52
|
+
if (response.ok) return { ok: true }
|
|
53
|
+
if (response.status === 404) return { ok: false, message: '配对链接无效或已过期。' }
|
|
54
|
+
if (response.status === 409) return { ok: false, message: '配对链接已被使用。' }
|
|
55
|
+
return { ok: false, message: '此设备无法使用该配对链接。' }
|
|
56
|
+
} catch {
|
|
57
|
+
return { ok: false, message: '无法连接到配对服务。' }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Consume a QR pairing token before the mobile application starts making RPC calls. */
|
|
62
|
+
export async function consumeMobilePairUrl(href: string, fetcher: PairFetch = fetch): Promise<MobilePairBootstrap> {
|
|
63
|
+
let url: URL
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(href)
|
|
66
|
+
} catch {
|
|
67
|
+
return { kind: 'none' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const token = url.searchParams.get('pair')
|
|
71
|
+
if (token === null || token === '') return { kind: 'none' }
|
|
72
|
+
|
|
73
|
+
const workspaceId = url.searchParams.get('workspace')
|
|
74
|
+
const path = mobilePairPath(workspaceId === null || workspaceId === '' ? undefined : workspaceId)
|
|
75
|
+
const result = await acceptMobilePair(token, fetcher)
|
|
76
|
+
return result.ok ? { kind: 'accepted', path } : { kind: 'failed', path, message: result.message }
|
|
77
|
+
}
|