@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9
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 +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17621
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -141
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -553
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -56
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -773
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -677
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -263
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1295
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/mobile-pending.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
2
|
-
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
3
|
-
|
|
4
|
-
export interface PendingApproval {
|
|
5
|
-
rpcId: string
|
|
6
|
-
approvalId: string
|
|
7
|
-
toolName: string
|
|
8
|
-
callId?: string
|
|
9
|
-
reason?: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface PendingQuestion {
|
|
13
|
-
rpcId: string
|
|
14
|
-
questions: Array<{
|
|
15
|
-
id: string
|
|
16
|
-
question: string
|
|
17
|
-
detail?: string
|
|
18
|
-
header?: string
|
|
19
|
-
options?: Array<{ label: string; description?: string }>
|
|
20
|
-
multiSelect?: boolean
|
|
21
|
-
}>
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface PendingState {
|
|
25
|
-
approvals: PendingApproval[]
|
|
26
|
-
questions: PendingQuestion[]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class PendingTracker {
|
|
30
|
-
private readonly sessions = new Map<string, { approvals: Map<string, PendingApproval>; questions: PendingQuestion[] }>()
|
|
31
|
-
|
|
32
|
-
/** Process one mux frame and update the pending state. */
|
|
33
|
-
onFrame(frame: RpcRequest<MuxFrame>): void {
|
|
34
|
-
const payload = frame.payload
|
|
35
|
-
if (payload.type === 'approval/requested') {
|
|
36
|
-
const state = this._getOrInit(payload.sessionId)
|
|
37
|
-
state.approvals.set(payload.approvalId, {
|
|
38
|
-
rpcId: frame.rpcId,
|
|
39
|
-
approvalId: payload.approvalId,
|
|
40
|
-
toolName: payload.toolName,
|
|
41
|
-
callId: payload.callId,
|
|
42
|
-
reason: payload.reason,
|
|
43
|
-
})
|
|
44
|
-
} else if (payload.type === 'approval/resolved') {
|
|
45
|
-
const state = this.sessions.get(payload.sessionId)
|
|
46
|
-
if (state) {
|
|
47
|
-
state.approvals.delete(payload.approvalId)
|
|
48
|
-
}
|
|
49
|
-
} else if (payload.type === 'question/requested') {
|
|
50
|
-
const state = this._getOrInit(payload.sessionId)
|
|
51
|
-
state.questions.push({
|
|
52
|
-
rpcId: frame.rpcId,
|
|
53
|
-
questions: payload.questions as any,
|
|
54
|
-
})
|
|
55
|
-
} else if (payload.type === 'question/resolved') {
|
|
56
|
-
const state = this.sessions.get(payload.sessionId)
|
|
57
|
-
if (state) {
|
|
58
|
-
state.questions = state.questions.filter(q => q.rpcId !== payload.questionRpcId)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private _getOrInit(sessionId: string) {
|
|
64
|
-
let state = this.sessions.get(sessionId)
|
|
65
|
-
if (!state) {
|
|
66
|
-
state = { approvals: new Map(), questions: [] }
|
|
67
|
-
this.sessions.set(sessionId, state)
|
|
68
|
-
}
|
|
69
|
-
return state
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Query pending items for a session. */
|
|
73
|
-
pending(sessionId: string): PendingState {
|
|
74
|
-
const state = this.sessions.get(sessionId)
|
|
75
|
-
if (!state) {
|
|
76
|
-
return { approvals: [], questions: [] }
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
approvals: Array.from(state.approvals.values()),
|
|
80
|
-
questions: state.questions,
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Clear all pending state for a session. */
|
|
85
|
-
clear(sessionId: string): void {
|
|
86
|
-
this.sessions.delete(sessionId)
|
|
87
|
-
}
|
|
88
|
-
}
|
package/src/mobile-pwa.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/** PWA registration for the standalone /m mobile surface. */
|
|
2
|
-
export const MOBILE_PWA_SCOPE = '/m/'
|
|
3
|
-
export const MOBILE_SERVICE_WORKER_URL = '/m/service-worker.js'
|
|
4
|
-
|
|
5
|
-
/** Register the mobile shell worker when the current browser supports it. */
|
|
6
|
-
export async function registerMobilePwa(
|
|
7
|
-
serviceWorker: Pick<ServiceWorkerContainer, 'register'> | undefined = typeof navigator === 'undefined' ? undefined : navigator.serviceWorker,
|
|
8
|
-
): Promise<void> {
|
|
9
|
-
if (serviceWorker === undefined) return
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
await serviceWorker.register(MOBILE_SERVICE_WORKER_URL, {
|
|
13
|
-
scope: MOBILE_PWA_SCOPE,
|
|
14
|
-
updateViaCache: 'none',
|
|
15
|
-
})
|
|
16
|
-
} catch {
|
|
17
|
-
// HTTP LAN origins cannot register workers; the online mobile UI still works.
|
|
18
|
-
}
|
|
19
|
-
}
|
package/src/mobile-routes.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The mobile surface's page routes: /m canonicalizes to /m/, which serves the
|
|
3
|
-
* standalone phone UI. The page and worker live in the /m/ scope so the PWA
|
|
4
|
-
* can cache only its static shell; the paired-device data channel remains at
|
|
5
|
-
* /m/api and is never handled by the worker.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readFile } from 'node:fs/promises'
|
|
9
|
-
import { existsSync } from 'node:fs'
|
|
10
|
-
import { fileURLToPath } from 'node:url'
|
|
11
|
-
import { gzipSync } from 'node:zlib'
|
|
12
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
|
-
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
14
|
-
|
|
15
|
-
const MOBILE_ROOT = '/m/'
|
|
16
|
-
const MOBILE_BUNDLE_URL = '/m/mobile.js'
|
|
17
|
-
const MOBILE_MANIFEST_URL = '/m/manifest.webmanifest'
|
|
18
|
-
const MOBILE_WORKER_URL = '/m/service-worker.js'
|
|
19
|
-
const MOBILE_OFFLINE_URL = '/m/offline.html'
|
|
20
|
-
|
|
21
|
-
/** The standalone mobile bundle (built artifact, next to this file's own lib output). */
|
|
22
|
-
function mobileBundlePath(): string {
|
|
23
|
-
return fileURLToPath(new URL('../lib/mobile.js', import.meta.url))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A package asset available to the host after a registry or git install. */
|
|
27
|
-
function mobileAssetPath(name: string): string {
|
|
28
|
-
return fileURLToPath(new URL('../assets/' + name, import.meta.url))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The mobile page shell: self-contained, with no document-level user data. */
|
|
32
|
-
function pageHtml(bundleUrl: string): string {
|
|
33
|
-
return [
|
|
34
|
-
'<!doctype html>',
|
|
35
|
-
'<html lang="zh-CN">',
|
|
36
|
-
'<head>',
|
|
37
|
-
'<meta charset="utf-8">',
|
|
38
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
|
|
39
|
-
'<meta name="theme-color" content="#f3f5f9">',
|
|
40
|
-
'<meta name="referrer" content="no-referrer">',
|
|
41
|
-
'<meta name="mobile-web-app-capable" content="yes">',
|
|
42
|
-
'<meta name="apple-mobile-web-app-capable" content="yes">',
|
|
43
|
-
'<meta name="apple-mobile-web-app-title" content="DSH Remote">',
|
|
44
|
-
'<link rel="manifest" href="/m/manifest.webmanifest">',
|
|
45
|
-
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
46
|
-
'<title>远程访问</title>',
|
|
47
|
-
'</head>',
|
|
48
|
-
'<body>',
|
|
49
|
-
'<div id="root"></div>',
|
|
50
|
-
'<script type="module" src="' + bundleUrl + '"></script>',
|
|
51
|
-
'</body>',
|
|
52
|
-
'</html>',
|
|
53
|
-
].join('')
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The installable app identity and icon declarations for the /m scope. */
|
|
57
|
-
function manifestJson(): string {
|
|
58
|
-
return JSON.stringify({
|
|
59
|
-
id: MOBILE_ROOT,
|
|
60
|
-
name: 'DSH Remote',
|
|
61
|
-
short_name: 'DSH Remote',
|
|
62
|
-
start_url: MOBILE_ROOT,
|
|
63
|
-
scope: MOBILE_ROOT,
|
|
64
|
-
display: 'standalone',
|
|
65
|
-
background_color: '#151424',
|
|
66
|
-
theme_color: '#f3f5f9',
|
|
67
|
-
icons: [
|
|
68
|
-
{ src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
69
|
-
{ src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
|
70
|
-
],
|
|
71
|
-
}, null, 2)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** A static fallback that contains no paired-device, session, or workspace data. */
|
|
75
|
-
function offlineHtml(): string {
|
|
76
|
-
return [
|
|
77
|
-
'<!doctype html>',
|
|
78
|
-
'<html lang="zh-CN">',
|
|
79
|
-
'<head>',
|
|
80
|
-
'<meta charset="utf-8">',
|
|
81
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
|
|
82
|
-
'<meta name="theme-color" content="#151424">',
|
|
83
|
-
'<title>DSH Remote</title>',
|
|
84
|
-
'<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>',
|
|
85
|
-
'</head>',
|
|
86
|
-
'<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>',
|
|
87
|
-
'</html>',
|
|
88
|
-
].join('')
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Send a small static UTF-8 body with revalidation headers. */
|
|
92
|
-
function writeStatic(res: ServerResponse, status: number, type: string, body: string): void {
|
|
93
|
-
res.writeHead(status, {
|
|
94
|
-
'content-type': type + '; charset=utf-8',
|
|
95
|
-
'cache-control': 'no-cache',
|
|
96
|
-
'referrer-policy': 'no-referrer',
|
|
97
|
-
})
|
|
98
|
-
res.end(body)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Send the worker with the same scope it registers for, without HTTP caching. */
|
|
102
|
-
function writeServiceWorker(res: ServerResponse, body: string): void {
|
|
103
|
-
res.writeHead(200, {
|
|
104
|
-
'content-type': 'text/javascript; charset=utf-8',
|
|
105
|
-
'cache-control': 'no-cache',
|
|
106
|
-
'referrer-policy': 'no-referrer',
|
|
107
|
-
'service-worker-allowed': MOBILE_ROOT,
|
|
108
|
-
})
|
|
109
|
-
res.end(body)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Send a PNG body without decoding it to UTF-8. */
|
|
113
|
-
function writePng(res: ServerResponse, status: number, body: Buffer): void {
|
|
114
|
-
res.writeHead(status, {
|
|
115
|
-
'content-type': 'image/png',
|
|
116
|
-
'cache-control': 'public, max-age=31536000, immutable',
|
|
117
|
-
'referrer-policy': 'no-referrer',
|
|
118
|
-
})
|
|
119
|
-
res.end(body)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Canonicalize the old /m route while retaining pair and workspace query parameters. */
|
|
123
|
-
function redirectToMobileRoot(req: IncomingMessage, res: ServerResponse): void {
|
|
124
|
-
const url = new URL(req.url ?? '/m', 'http://x')
|
|
125
|
-
res.writeHead(308, {
|
|
126
|
-
location: MOBILE_ROOT + url.search,
|
|
127
|
-
'cache-control': 'no-store',
|
|
128
|
-
'referrer-policy': 'no-referrer',
|
|
129
|
-
})
|
|
130
|
-
res.end()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Build the mobile page routes.
|
|
135
|
-
* @returns Exact routes for the canonical page, static shell, and PWA assets.
|
|
136
|
-
*/
|
|
137
|
-
export function makeMobileRoutes(): WebRoute[] {
|
|
138
|
-
const handlePage = (_req: IncomingMessage, res: ServerResponse): void => {
|
|
139
|
-
writeStatic(res, 200, 'text/html', pageHtml(MOBILE_BUNDLE_URL))
|
|
140
|
-
}
|
|
141
|
-
const handleManifest = (_req: IncomingMessage, res: ServerResponse): void => {
|
|
142
|
-
writeStatic(res, 200, 'application/manifest+json', manifestJson())
|
|
143
|
-
}
|
|
144
|
-
const handleOffline = (_req: IncomingMessage, res: ServerResponse): void => {
|
|
145
|
-
writeStatic(res, 200, 'text/html', offlineHtml())
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// The bundle is immutable for the process lifetime (a rebuild requires a
|
|
149
|
-
// host restart), so the body is read from disk once, not per phone.
|
|
150
|
-
let bundleBody: string | undefined
|
|
151
|
-
let bundleGzip: Buffer | undefined
|
|
152
|
-
const handleBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
153
|
-
if (bundleBody === undefined) {
|
|
154
|
-
const path = mobileBundlePath()
|
|
155
|
-
if (!existsSync(path)) {
|
|
156
|
-
writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
bundleBody = await readFile(path, 'utf8')
|
|
161
|
-
bundleGzip = gzipSync(Buffer.from(bundleBody, 'utf8'))
|
|
162
|
-
} catch {
|
|
163
|
-
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
const acceptEncoding = req.headers['accept-encoding'] ?? ''
|
|
168
|
-
const acceptsGzip = typeof acceptEncoding === 'string' && acceptEncoding.includes('gzip') && bundleGzip !== undefined
|
|
169
|
-
if (acceptsGzip) {
|
|
170
|
-
res.writeHead(200, {
|
|
171
|
-
'content-type': 'text/javascript; charset=utf-8',
|
|
172
|
-
'content-encoding': 'gzip',
|
|
173
|
-
'cache-control': 'no-cache',
|
|
174
|
-
vary: 'Accept-Encoding',
|
|
175
|
-
'referrer-policy': 'no-referrer',
|
|
176
|
-
})
|
|
177
|
-
res.end(bundleGzip)
|
|
178
|
-
} else {
|
|
179
|
-
res.writeHead(200, {
|
|
180
|
-
'content-type': 'text/javascript; charset=utf-8',
|
|
181
|
-
'cache-control': 'no-cache',
|
|
182
|
-
vary: 'Accept-Encoding',
|
|
183
|
-
'referrer-policy': 'no-referrer',
|
|
184
|
-
})
|
|
185
|
-
res.end(bundleBody)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let workerBody: string | undefined
|
|
190
|
-
const handleWorker = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
191
|
-
if (workerBody === undefined) {
|
|
192
|
-
const path = mobileAssetPath('mobile-service-worker.js')
|
|
193
|
-
if (!existsSync(path)) {
|
|
194
|
-
writeStatic(res, 503, 'text/plain', 'mobile service worker not found')
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
try {
|
|
198
|
-
workerBody = await readFile(path, 'utf8')
|
|
199
|
-
} catch {
|
|
200
|
-
writeStatic(res, 500, 'text/plain', 'failed to read the mobile service worker')
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
writeServiceWorker(res, workerBody)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const makeIconHandler = (asset: string): WebRoute['handler'] => async (_req, res) => {
|
|
208
|
-
const path = mobileAssetPath(asset)
|
|
209
|
-
if (!existsSync(path)) {
|
|
210
|
-
writeStatic(res, 404, 'text/plain', asset + ' not found')
|
|
211
|
-
return
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
writePng(res, 200, await readFile(path))
|
|
215
|
-
} catch {
|
|
216
|
-
writeStatic(res, 500, 'text/plain', 'failed to read ' + asset)
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return [
|
|
221
|
-
{ kind: 'exact', path: '/m', handler: redirectToMobileRoot },
|
|
222
|
-
{ kind: 'exact', path: MOBILE_ROOT, handler: handlePage },
|
|
223
|
-
{ kind: 'exact', path: MOBILE_BUNDLE_URL, handler: handleBundle },
|
|
224
|
-
{ kind: 'exact', path: MOBILE_MANIFEST_URL, handler: handleManifest },
|
|
225
|
-
{ kind: 'exact', path: MOBILE_WORKER_URL, handler: handleWorker },
|
|
226
|
-
{ kind: 'exact', path: MOBILE_OFFLINE_URL, handler: handleOffline },
|
|
227
|
-
{ kind: 'exact', path: '/m/apple-touch-icon.png', handler: makeIconHandler('apple-touch-icon.png') },
|
|
228
|
-
{ kind: 'exact', path: '/m/icon-192.png', handler: makeIconHandler('mobile-icon-192.png') },
|
|
229
|
-
{ kind: 'exact', path: '/m/icon-512.png', handler: makeIconHandler('mobile-icon-512.png') },
|
|
230
|
-
]
|
|
231
|
-
}
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
/** Paired-only model discovery and adoption routes; generic privileged RPCs stay loopback-only. */
|
|
2
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
-
import type { ApiProxy, ModelProviderGroup, RpcId, RpcResponse, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
|
4
|
-
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
5
|
-
import { z } from 'zod'
|
|
6
|
-
import type { PairingService } from './pairing.ts'
|
|
7
|
-
import { readCookie } from './gate.ts'
|
|
8
|
-
import { readBoundedJson, writeJson } from './http.ts'
|
|
9
|
-
import { isTrustedApiRequest, publicHostOf } from './routes.ts'
|
|
10
|
-
|
|
11
|
-
const MAX_BODY_BYTES = 16 * 1024
|
|
12
|
-
const MAX_IDENTIFIER_LENGTH = 160
|
|
13
|
-
const MAX_DISPLAY_NAME_LENGTH = 240
|
|
14
|
-
const MAX_TOKEN_LIMIT = 10_000_000
|
|
15
|
-
const REASONING_EFFORTS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
16
|
-
type ReasoningEffort = typeof REASONING_EFFORTS[number]
|
|
17
|
-
const stringField = (max: number) => z.string().trim().min(1).max(max).refine(value => !/[\r\n\0]/.test(value), 'must not contain a newline or NUL')
|
|
18
|
-
const providerSchema = z.object({ provider: stringField(MAX_IDENTIFIER_LENGTH) }).strict()
|
|
19
|
-
const modelSchema = z.object({
|
|
20
|
-
id: stringField(MAX_IDENTIFIER_LENGTH),
|
|
21
|
-
name: stringField(MAX_DISPLAY_NAME_LENGTH).optional(),
|
|
22
|
-
contextWindow: z.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
23
|
-
maxTokens: z.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
24
|
-
reasoningEfforts: z.array(z.enum(REASONING_EFFORTS)).min(1).refine(
|
|
25
|
-
efforts => efforts.every((effort, index) => index === 0 || REASONING_EFFORTS.indexOf(efforts[index - 1]!) < REASONING_EFFORTS.indexOf(effort)),
|
|
26
|
-
'reasoning efforts must be unique and in canonical order',
|
|
27
|
-
).optional(),
|
|
28
|
-
}).strict()
|
|
29
|
-
const upsertSchema = z.object({ provider: stringField(MAX_IDENTIFIER_LENGTH), model: modelSchema }).strict()
|
|
30
|
-
|
|
31
|
-
interface ModelProfile { id: string, name?: string, contextWindow?: number, maxTokens?: number, reasoningEfforts?: ReasoningEffort[] }
|
|
32
|
-
interface EligibleProvider { provider: string, displayName: string, namespace: SettingsNamespaceView, profile: Record<string, unknown> }
|
|
33
|
-
interface CatalogValue { groups: ModelProviderGroup[], failures: Array<{ id: string, name: string, message: string }> }
|
|
34
|
-
|
|
35
|
-
export const PAIRED_MODEL_CATALOG_PATHS = {
|
|
36
|
-
catalog: '/api/pair/model-catalog',
|
|
37
|
-
discover: '/api/pair/model-catalog/discover',
|
|
38
|
-
upsert: '/api/pair/model-catalog/upsert',
|
|
39
|
-
} as const
|
|
40
|
-
|
|
41
|
-
export interface PairedModelCatalogDeps {
|
|
42
|
-
service: PairingService
|
|
43
|
-
apiProxy: ApiProxy
|
|
44
|
-
/** The LAN IP literals the host fence accepts, mirroring the /api/pair fence. */
|
|
45
|
-
lanAddresses: readonly string[]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let rpcSequence = 0
|
|
49
|
-
function request<T>(payload: T): { rpcId: RpcId, payload: T } {
|
|
50
|
-
rpcSequence += 1
|
|
51
|
-
return { rpcId: `paired-model-catalog-${Date.now().toString(36)}-${rpcSequence.toString(36)}` as RpcId, payload }
|
|
52
|
-
}
|
|
53
|
-
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
|
|
54
|
-
function valueOf<T>(response: RpcResponse<T>): T | undefined { return response.result.ok ? response.result.value : undefined }
|
|
55
|
-
function errorCode(response: RpcResponse<unknown>): string | undefined { return response.result.ok ? undefined : response.result.error.code }
|
|
56
|
-
function reject(res: ServerResponse, status: number, error: string): void { writeJson(res, status, { error }) }
|
|
57
|
-
function publicProviderId(value: string): string {
|
|
58
|
-
const normalized = value.replace(/[\r\n\0]/g, ' ').trim().slice(0, MAX_IDENTIFIER_LENGTH)
|
|
59
|
-
return normalized === '' ? 'unknown' : normalized
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function publicCatalog(value: CatalogValue): CatalogValue {
|
|
63
|
-
return {
|
|
64
|
-
groups: value.groups.map(group => ({ id: group.id, name: group.name, models: group.models.map(model => ({
|
|
65
|
-
id: model.id, name: model.name,
|
|
66
|
-
...model.description === undefined ? {} : { description: model.description },
|
|
67
|
-
...model.reasoning === undefined ? {} : { reasoning: model.reasoning },
|
|
68
|
-
})) })),
|
|
69
|
-
failures: value.failures.map(failure => ({
|
|
70
|
-
id: failure.id,
|
|
71
|
-
name: failure.name,
|
|
72
|
-
message: `model catalog unavailable for provider ${publicProviderId(failure.id)}`,
|
|
73
|
-
})),
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function publicCandidates(models: Array<{ id: string, name?: string, contextWindow?: number, maxTokens?: number }>): unknown[] {
|
|
77
|
-
return models.map(model => ({ id: model.id,
|
|
78
|
-
...model.name === undefined ? {} : { name: model.name },
|
|
79
|
-
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
|
80
|
-
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
|
81
|
-
}))
|
|
82
|
-
}
|
|
83
|
-
function fieldsOf(model: ModelProfile, includeId: boolean): Record<string, unknown> {
|
|
84
|
-
const fields: Record<string, unknown> = {
|
|
85
|
-
...includeId ? { id: model.id } : {},
|
|
86
|
-
...model.name === undefined ? {} : { name: model.name },
|
|
87
|
-
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
|
88
|
-
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
|
89
|
-
}
|
|
90
|
-
if (model.reasoningEfforts !== undefined) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map(effort => [effort, effort === 'off' ? null : effort]))
|
|
91
|
-
return fields
|
|
92
|
-
}
|
|
93
|
-
function groupFor(catalog: CatalogValue, provider: string): ModelProviderGroup | undefined {
|
|
94
|
-
if (catalog.failures.some(failure => failure.id === provider)) return undefined
|
|
95
|
-
return catalog.groups.find(group => group.id === provider)
|
|
96
|
-
}
|
|
97
|
-
async function readCatalog(apiProxy: ApiProxy): Promise<CatalogValue | undefined> {
|
|
98
|
-
try { return valueOf(await apiProxy.llm.models(request({}))) } catch { return undefined }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Resolve one exact active llm-pi-ai provider address, never an input-supplied path. */
|
|
102
|
-
async function eligibleProvider(apiProxy: ApiProxy, requested: string): Promise<{ provider?: EligibleProvider, status?: number, error?: string }> {
|
|
103
|
-
let providersResponse: Awaited<ReturnType<ApiProxy['llm']['providers']>>
|
|
104
|
-
let settingsResponse: Awaited<ReturnType<ApiProxy['settings']['describe']>>
|
|
105
|
-
try {
|
|
106
|
-
providersResponse = await apiProxy.llm.providers(request({}))
|
|
107
|
-
settingsResponse = await apiProxy.settings.describe(request({}))
|
|
108
|
-
} catch { return { status: 502, error: 'model catalog capability is unavailable' } }
|
|
109
|
-
const providers = valueOf(providersResponse)
|
|
110
|
-
const settings = valueOf(settingsResponse)
|
|
111
|
-
if (providers === undefined || settings === undefined) return { status: 502, error: 'model catalog capability is unavailable' }
|
|
112
|
-
const route = providers.providers.find(candidate => candidate.provider === requested)
|
|
113
|
-
if (route === undefined) return { status: 404, error: `unknown provider ${requested}` }
|
|
114
|
-
if (!settings.writable || !route.active || route.settingsNs !== 'llm-pi-ai' || route.settingsPath.length !== 2
|
|
115
|
-
|| route.settingsPath[0] !== 'providers' || route.settingsPath[1] !== requested) return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` }
|
|
116
|
-
const namespace = settings.namespaces.find(candidate => candidate.ns === 'llm-pi-ai')
|
|
117
|
-
const root = namespace !== undefined && isRecord(namespace.value) ? namespace.value : undefined
|
|
118
|
-
const providersValue = root !== undefined && isRecord(root.providers) ? root.providers : undefined
|
|
119
|
-
const profile = providersValue !== undefined && isRecord(providersValue[requested]) ? providersValue[requested] : undefined
|
|
120
|
-
if (namespace === undefined || profile === undefined) return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` }
|
|
121
|
-
return { provider: { provider: requested, displayName: route.displayName, namespace, profile } }
|
|
122
|
-
}
|
|
123
|
-
function validIdentifier(value: unknown): value is string {
|
|
124
|
-
const parsed = stringField(MAX_IDENTIFIER_LENGTH).safeParse(value)
|
|
125
|
-
return parsed.success && parsed.data === value
|
|
126
|
-
}
|
|
127
|
-
function configuredModels(profile: Record<string, unknown>): { inherited: true } | { inherited: false, values: Record<string, unknown>[] } | undefined {
|
|
128
|
-
if (!Object.hasOwn(profile, 'models')) return { inherited: true }
|
|
129
|
-
if (!Array.isArray(profile.models)) return undefined
|
|
130
|
-
if (profile.models.length === 0) return { inherited: true }
|
|
131
|
-
const values: Record<string, unknown>[] = []
|
|
132
|
-
for (const entry of profile.models) {
|
|
133
|
-
if (!isRecord(entry) || !validIdentifier(entry.id)) return undefined
|
|
134
|
-
values.push({ ...entry })
|
|
135
|
-
}
|
|
136
|
-
return { inherited: false, values }
|
|
137
|
-
}
|
|
138
|
-
function configuredOverrides(profile: Record<string, unknown>): { present: boolean, values: Map<string, Record<string, unknown>> } | undefined {
|
|
139
|
-
if (!Object.hasOwn(profile, 'modelOverrides')) return { present: false, values: new Map() }
|
|
140
|
-
if (!isRecord(profile.modelOverrides)) return undefined
|
|
141
|
-
const values = new Map<string, Record<string, unknown>>()
|
|
142
|
-
for (const [id, value] of Object.entries(profile.modelOverrides)) {
|
|
143
|
-
if (!validIdentifier(id) || !isRecord(value)) return undefined
|
|
144
|
-
values.set(id, { ...value })
|
|
145
|
-
}
|
|
146
|
-
return { present: true, values }
|
|
147
|
-
}
|
|
148
|
-
function updatedModels(existing: Record<string, unknown>[], model: ModelProfile): Record<string, unknown>[] {
|
|
149
|
-
const next = existing.map(entry => ({ ...entry }))
|
|
150
|
-
const index = next.findIndex(entry => entry.id === model.id)
|
|
151
|
-
const patch = fieldsOf(model, true)
|
|
152
|
-
if (index >= 0) next[index] = { ...next[index], ...patch }
|
|
153
|
-
else next.push(patch)
|
|
154
|
-
return next
|
|
155
|
-
}
|
|
156
|
-
function mutationPlan(profile: Record<string, unknown>, provider: string, model: ModelProfile, catalog: CatalogValue): { ops: SettingsPathOpView[] } | undefined {
|
|
157
|
-
const configured = configuredModels(profile)
|
|
158
|
-
const overrides = configuredOverrides(profile)
|
|
159
|
-
if (configured === undefined || overrides === undefined) return undefined
|
|
160
|
-
const modelsPath = ['providers', provider, 'models']
|
|
161
|
-
const overridesPath = ['providers', provider, 'modelOverrides']
|
|
162
|
-
if (!configured.inherited) {
|
|
163
|
-
if (overrides.values.size > 0) return undefined
|
|
164
|
-
const ops: SettingsPathOpView[] = [{ op: 'set', path: modelsPath, value: updatedModels(configured.values, model) }]
|
|
165
|
-
if (overrides.present) ops.push({ op: 'unset', path: overridesPath })
|
|
166
|
-
return { ops }
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const group = groupFor(catalog, provider)
|
|
170
|
-
if (group?.models.some(entry => entry.id === model.id) === true) {
|
|
171
|
-
const existing = overrides.values.get(model.id) ?? {}
|
|
172
|
-
return { ops: [{ op: 'set', path: [...overridesPath, model.id], value: { ...existing, ...fieldsOf(model, false) } }] }
|
|
173
|
-
}
|
|
174
|
-
if (group === undefined) return undefined
|
|
175
|
-
const materialized: Record<string, unknown>[] = []
|
|
176
|
-
const materializedIds = new Set<string>()
|
|
177
|
-
for (const installed of group.models) {
|
|
178
|
-
materialized.push({ ...overrides.values.get(installed.id), id: installed.id })
|
|
179
|
-
materializedIds.add(installed.id)
|
|
180
|
-
}
|
|
181
|
-
for (const [id, override] of overrides.values) {
|
|
182
|
-
if (!materializedIds.has(id)) materialized.push({ ...override, id })
|
|
183
|
-
}
|
|
184
|
-
const ops: SettingsPathOpView[] = [{ op: 'set', path: modelsPath, value: updatedModels(materialized, model) }]
|
|
185
|
-
if (overrides.present) ops.push({ op: 'unset', path: overridesPath })
|
|
186
|
-
return { ops }
|
|
187
|
-
}
|
|
188
|
-
function paired(req: IncomingMessage, service: PairingService): boolean {
|
|
189
|
-
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
190
|
-
return deviceId !== undefined && service.touchDevice(deviceId)
|
|
191
|
-
}
|
|
192
|
-
async function bodyOf<T>(req: IncomingMessage, schema: z.ZodType<T>): Promise<T | undefined> {
|
|
193
|
-
try { return schema.parse(await readBoundedJson(req, MAX_BODY_BYTES)) } catch { return undefined }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Build the narrow paired-device model catalog routes. */
|
|
197
|
-
export function makePairedModelCatalogRoutes(deps: PairedModelCatalogDeps): WebRoute[] {
|
|
198
|
-
const { service, apiProxy, lanAddresses } = deps
|
|
199
|
-
/** Same fence as the /api/pair family: loopback, the advertised LAN literals, or the configured public host. */
|
|
200
|
-
const fence = (req: IncomingMessage): boolean => {
|
|
201
|
-
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
202
|
-
return isTrustedApiRequest(req, publicHost === undefined ? lanAddresses : [...lanAddresses, publicHost])
|
|
203
|
-
}
|
|
204
|
-
const gate = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
205
|
-
if (!fence(req)) {
|
|
206
|
-
req.resume()
|
|
207
|
-
reject(res, 403, 'paired model catalog is not reachable for this origin')
|
|
208
|
-
return false
|
|
209
|
-
}
|
|
210
|
-
if (!paired(req, service)) {
|
|
211
|
-
req.resume()
|
|
212
|
-
reject(res, 403, 'paired model catalog requires a live paired device')
|
|
213
|
-
return false
|
|
214
|
-
}
|
|
215
|
-
return true
|
|
216
|
-
}
|
|
217
|
-
const catalog = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
218
|
-
if (!gate(req, res)) return
|
|
219
|
-
if (req.method !== 'GET') return reject(res, 405, 'method not allowed')
|
|
220
|
-
let providersResponse: Awaited<ReturnType<ApiProxy['llm']['providers']>>
|
|
221
|
-
let settingsResponse: Awaited<ReturnType<ApiProxy['settings']['describe']>>
|
|
222
|
-
try { providersResponse = await apiProxy.llm.providers(request({})); settingsResponse = await apiProxy.settings.describe(request({})) } catch { return reject(res, 502, 'model catalog capability is unavailable') }
|
|
223
|
-
const providers = valueOf(providersResponse)
|
|
224
|
-
const settings = valueOf(settingsResponse)
|
|
225
|
-
if (providers === undefined || settings === undefined) return reject(res, 502, 'model catalog capability is unavailable')
|
|
226
|
-
const namespace = settings.namespaces.find(candidate => candidate.ns === 'llm-pi-ai')
|
|
227
|
-
const providerMap = namespace !== undefined && isRecord(namespace.value) && isRecord(namespace.value.providers) ? namespace.value.providers : undefined
|
|
228
|
-
const eligible = settings.writable && providerMap !== undefined ? providers.providers.filter(candidate => candidate.active && candidate.settingsNs === 'llm-pi-ai'
|
|
229
|
-
&& candidate.settingsPath.length === 2 && candidate.settingsPath[0] === 'providers' && candidate.settingsPath[1] === candidate.provider && isRecord(providerMap[candidate.provider]))
|
|
230
|
-
.map(candidate => ({ provider: candidate.provider, displayName: candidate.displayName })) : []
|
|
231
|
-
writeJson(res, 200, { capability: 'paired-model-catalog', providers: eligible })
|
|
232
|
-
}
|
|
233
|
-
const discover = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
234
|
-
if (!gate(req, res)) return
|
|
235
|
-
if (req.method !== 'POST') return reject(res, 405, 'method not allowed')
|
|
236
|
-
const body = await bodyOf(req, providerSchema)
|
|
237
|
-
if (body === undefined) return reject(res, 400, 'invalid model catalog request')
|
|
238
|
-
const eligible = await eligibleProvider(apiProxy, body.provider)
|
|
239
|
-
if (eligible.provider === undefined) return reject(res, eligible.status ?? 502, eligible.error ?? 'model catalog capability is unavailable')
|
|
240
|
-
try {
|
|
241
|
-
const result = valueOf(await apiProxy.llm.discoverModels(request({ settingsNs: 'llm-pi-ai', provider: eligible.provider.provider })))
|
|
242
|
-
if (result === undefined) return reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`)
|
|
243
|
-
writeJson(res, 200, { models: publicCandidates(result.models) })
|
|
244
|
-
} catch { reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`) }
|
|
245
|
-
}
|
|
246
|
-
const upsert = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
247
|
-
if (!gate(req, res)) return
|
|
248
|
-
if (req.method !== 'POST') return reject(res, 405, 'method not allowed')
|
|
249
|
-
const body = await bodyOf(req, upsertSchema)
|
|
250
|
-
if (body === undefined) return reject(res, 400, 'invalid model catalog request')
|
|
251
|
-
const eligible = await eligibleProvider(apiProxy, body.provider)
|
|
252
|
-
if (eligible.provider === undefined) return reject(res, eligible.status ?? 502, eligible.error ?? 'model catalog capability is unavailable')
|
|
253
|
-
const before = await readCatalog(apiProxy)
|
|
254
|
-
if (before === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
|
|
255
|
-
const plan = mutationPlan(eligible.provider.profile, body.provider, body.model, before)
|
|
256
|
-
if (plan === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
|
|
257
|
-
let mutation: Awaited<ReturnType<ApiProxy['settings']['mutate']>>
|
|
258
|
-
try { mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: plan.ops })) } catch { return reject(res, 502, `model update failed for provider ${body.provider}`) }
|
|
259
|
-
const code = errorCode(mutation)
|
|
260
|
-
if (code === 'settings-conflict') return reject(res, 409, `model update conflicted for provider ${body.provider}`)
|
|
261
|
-
if (code === 'settings-rejected') return reject(res, 422, `model update was rejected for provider ${body.provider}`)
|
|
262
|
-
if (code !== undefined) return reject(res, 502, `model update failed for provider ${body.provider}`)
|
|
263
|
-
const after = await readCatalog(apiProxy)
|
|
264
|
-
if (after === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
|
|
265
|
-
writeJson(res, 200, publicCatalog(after))
|
|
266
|
-
}
|
|
267
|
-
return [
|
|
268
|
-
{ kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.catalog, handler: catalog },
|
|
269
|
-
{ kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.discover, handler: discover },
|
|
270
|
-
{ kind: 'exact', path: PAIRED_MODEL_CATALOG_PATHS.upsert, handler: upsert },
|
|
271
|
-
]
|
|
272
|
-
}
|