@linxin666/dsh-remote-web-ui 0.3.6 → 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.
Files changed (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -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
- }
@@ -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
- }