@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.
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 -17621
  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 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  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 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  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 -231
  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 -677
  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 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  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,213 +0,0 @@
1
- /**
2
- * Mobile surface root: the view state machine (workspaces → sessions →
3
- * chat) and the top-level data flows. Deliberately plain React state — no
4
- * router, no state library: the surface is three fixed levels with a back
5
- * affordance, and every piece of data is fetched on demand.
6
- */
7
-
8
- import { useCallback, useEffect, useRef, useState } from 'react'
9
- import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
- import { fetchMobilePreferences, history as fetchHistory, listSessions, listWorkspaces, prompt } from '../api.ts'
11
- import { MuxClient } from '../mux.ts'
12
- import { RpcCallError, RpcTransportError } from '../rpc.ts'
13
- import { ChatView } from './ChatView.tsx'
14
- import { SessionListView } from './SessionListView.tsx'
15
- import { WorkspaceView as WorkspaceRoster } from './WorkspaceView.tsx'
16
- import { PairRequiredView } from '../PairRequiredView.tsx'
17
-
18
- /** One navigation level. */
19
- type Route =
20
- | { kind: 'workspaces' }
21
- | { kind: 'sessions'; workspace: WorkspaceRow }
22
- | { kind: 'chat'; session: SessionView; workspace: WorkspaceRow }
23
-
24
- /** The session-list row model (list + chat share it). */
25
- export interface SessionView {
26
- sessionId: string
27
- title: string
28
- cwd?: string
29
- updatedAt: number
30
- running: boolean
31
- blank: boolean
32
- }
33
-
34
- /** Read the optional workspace target carried from the pairing QR flow. */
35
- export function mobileWorkspaceTarget(search: string): string | undefined {
36
- const value = new URLSearchParams(search).get('workspace')
37
- return value === null || value === '' ? undefined : value
38
- }
39
-
40
- /** Map a list row to the surface model; the title comes from projections when present. */
41
- export function toSessionView(item: {
42
- sessionId: string
43
- updatedAt: number
44
- running: boolean
45
- blank: boolean
46
- cwd?: string
47
- projections?: { values?: Record<string, unknown> }
48
- }): SessionView {
49
- const titleValue = item.projections?.values?.title
50
- const title = typeof titleValue === 'string' && titleValue !== ''
51
- ? titleValue
52
- : item.cwd !== undefined ? item.cwd.split('/').filter(Boolean).at(-1) ?? item.cwd : '新会话'
53
- return {
54
- sessionId: item.sessionId,
55
- title,
56
- ...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
57
- updatedAt: item.updatedAt,
58
- running: item.running,
59
- blank: item.blank,
60
- }
61
- }
62
-
63
- /** Human clock, e.g. "14:05" or "昨天 20:31". */
64
- export function formatTime(epochMs: number): string {
65
- const date = new Date(epochMs)
66
- const clock = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
67
- const today = new Date()
68
- if (date.toDateString() === today.toDateString()) return clock
69
- const yesterday = new Date(today)
70
- yesterday.setDate(today.getDate() - 1)
71
- if (date.toDateString() === yesterday.toDateString()) return `昨天 ${clock}`
72
- return `${String(date.getMonth() + 1)}月${String(date.getDate())}日 ${clock}`
73
- }
74
-
75
- /** Props accepted by the mobile root before it begins paired-device RPC calls. */
76
- export interface AppProps {
77
- initialPairError?: string
78
- }
79
-
80
- /** Whether the mobile gateway rejected this browser for lack of a paired cookie. */
81
- export function isUnpairedMobileError(error: unknown): boolean {
82
- return error instanceof RpcTransportError && error.message === 'HTTP 403'
83
- }
84
-
85
- /** The result of probing the paired-device-only mobile preference endpoint. */
86
- export type MobilePairState = 'checking' | 'paired' | 'unpaired' | 'unavailable'
87
-
88
- /** Classify an initial mobile preference failure without treating outage as authorization. */
89
- export function mobilePairStateForError(error: unknown): Extract<MobilePairState, 'unpaired' | 'unavailable'> {
90
- return isUnpairedMobileError(error) ? 'unpaired' : 'unavailable'
91
- }
92
-
93
- /** Gate the independent mobile bundle until its own browser context is paired. */
94
- export function App({ initialPairError }: AppProps) {
95
- const [pairState, setPairState] = useState<MobilePairState>('checking')
96
-
97
- useEffect(() => {
98
- let current = true
99
- void fetchMobilePreferences().then(
100
- () => { if (current) setPairState('paired') },
101
- (error: unknown) => { if (current) setPairState(mobilePairStateForError(error)) },
102
- )
103
- return () => { current = false }
104
- }, [])
105
-
106
- if (pairState === 'checking') {
107
- return <main className="mobile mobile-empty"><p className="mobile-muted">正在连接...</p></main>
108
- }
109
- if (pairState === 'unpaired') {
110
- return <PairRequiredView initialError={initialPairError} onPaired={(path) => { window.location.replace(path) }} />
111
- }
112
- if (pairState === 'unavailable') {
113
- return (
114
- <main className="mobile mobile-empty">
115
- <p className="mobile-error" role="alert">无法连接到运行中的 DSH host。</p>
116
- <button className="mobile-new" type="button" onClick={() => { window.location.reload() }}>重试</button>
117
- </main>
118
- )
119
- }
120
-
121
- return <PairedApp />
122
- }
123
-
124
- /** The existing remote mobile surface, mounted only after device pairing succeeds. */
125
- function PairedApp() {
126
- const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
127
- const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
128
- () => mobileWorkspaceTarget(window.location.search),
129
- )
130
- const muxRef = useRef<MuxClient | undefined>(undefined)
131
-
132
- // The mux stream lives for the page lifetime: session events keep the
133
- // open chat live, and reconnect is automatic.
134
- useEffect(() => {
135
- const mux = new MuxClient()
136
- muxRef.current = mux
137
- mux.start()
138
- return () => { mux.stop() }
139
- }, [])
140
-
141
- // Keep the live-event client pointed at the session currently on screen so
142
- // its polling fallback can keep that chat fresh over SSE-impairing tunnels
143
- // (quick tunnel / Tailscale Serve do not forward Server-Sent Events).
144
- useEffect(() => {
145
- muxRef.current?.observe(route.kind === 'chat' ? route.session.sessionId : undefined)
146
- }, [route])
147
-
148
- const back = useCallback(() => {
149
- setRoute(previous => {
150
- if (previous.kind === 'chat') return { kind: 'sessions', workspace: previous.workspace }
151
- if (previous.kind === 'sessions') return { kind: 'workspaces' }
152
- return previous
153
- })
154
- }, [])
155
-
156
- const openChat = useCallback((session: SessionView, workspace: WorkspaceRow) => {
157
- setRoute({ kind: 'chat', session, workspace })
158
- }, [])
159
-
160
- const openWorkspace = useCallback((workspace: WorkspaceRow) => {
161
- if (initialWorkspaceId !== undefined) {
162
- const url = new URL(window.location.href)
163
- url.searchParams.delete('workspace')
164
- window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
165
- setInitialWorkspaceId(undefined)
166
- }
167
- setRoute({ kind: 'sessions', workspace })
168
- }, [initialWorkspaceId])
169
-
170
- return (
171
- <div className="mobile">
172
- {route.kind === 'workspaces'
173
- ? <WorkspaceRoster initialWorkspaceId={initialWorkspaceId} onPick={openWorkspace} />
174
- : route.kind === 'sessions'
175
- ? (
176
- <SessionListView
177
- workspace={route.workspace}
178
- onBack={back}
179
- onPick={(session) => { openChat(session, route.workspace) }}
180
- />
181
- )
182
- : <ChatView session={route.session} mux={muxRef.current} onBack={back} />}
183
- </div>
184
- )
185
- }
186
-
187
- /** Shared error text for the surface's small failure affordances. */
188
- export function errorText(error: unknown): string {
189
- if (error instanceof RpcCallError) return error.error.message
190
- if (error instanceof RpcTransportError) return error.message
191
- if (error instanceof Error) return error.message
192
- return String(error)
193
- }
194
-
195
- /**
196
- * Actionable hint for transport-level 403s on host-gated channels (model
197
- * picker, session creation): the phone's UI bundle is served fresh from disk
198
- * per request, while the host-side allowlist lives in the long-running
199
- * process — a rebuild without a restart shows the new surface against the
200
- * old allowlist (HTTP 403 forbidden).
201
- */
202
- export function staleHostHint(message: string): string | undefined {
203
- return /^HTTP 403/.test(message)
204
- ? '宿主端插件可能仍在运行旧版本:请重启 dsh web 后再试。'
205
- : undefined
206
- }
207
-
208
- /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
209
- export function loadHistory(sessionId: string, beforeSeq?: number, signal?: AbortSignal) {
210
- return fetchHistory(sessionId, beforeSeq, undefined, signal)
211
- }
212
-
213
- export { listSessions, listWorkspaces, prompt }
@@ -1,263 +0,0 @@
1
- // @vitest-environment jsdom
2
- /**
3
- * ChatView streaming markdown throttle: a pending assistant message must not
4
- * re-parse its whole accumulated text on every chunk. Streaming text
5
- * re-parses at most once per STREAM_RENDER_INTERVAL_MS, shows the last
6
- * parsed result in between, and renders the exact final HTML (byte-identical
7
- * to the plain renderMarkdown output) the moment the turn closes.
8
- */
9
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
- import { act, cleanup, render, screen } from '@testing-library/react'
11
- import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
12
- import { type SessionView } from './App.tsx'
13
- import { renderMarkdown } from '../markdown.ts'
14
- import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
15
- import type { HistoryPage } from '../api.ts'
16
- import type { WireEvent } from '../messages.ts'
17
-
18
- // The api module is fully mocked; App.tsx's history wrapper is overridden to
19
- // feed a fixed empty history page, its pure helpers stay real.
20
- vi.mock('../api.ts', () => ({
21
- fetchMobilePreferences: vi.fn(),
22
- models: vi.fn(),
23
- selectModel: vi.fn(),
24
- sendCommand: vi.fn(),
25
- cancelSession: vi.fn(),
26
- fetchPending: vi.fn(),
27
- }))
28
- vi.mock('./App.tsx', async importOriginal => {
29
- const actual = await importOriginal<typeof import('./App.tsx')>()
30
- return {
31
- ...actual,
32
- loadHistory: vi.fn(),
33
- prompt: vi.fn(async () => {}),
34
- }
35
- })
36
- // Wrap the real renderer with a call counter: the throttle must cut the
37
- // number of full-text parses during a chunk stream (each parse is O(n)).
38
- vi.mock('../markdown.ts', async importOriginal => {
39
- const actual = await importOriginal<typeof import('../markdown.ts')>()
40
- return { ...actual, renderMarkdown: vi.fn(actual.renderMarkdown) }
41
- })
42
- import { fetchMobilePreferences, models } from '../api.ts'
43
- import { loadHistory } from './App.tsx'
44
-
45
- const loadHistoryMock = vi.mocked(loadHistory)
46
- const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
47
- const modelsMock = vi.mocked(models)
48
- const renderMarkdownMock = vi.mocked(renderMarkdown)
49
-
50
- const session: SessionView = {
51
- sessionId: 's-1',
52
- title: '测试会话',
53
- updatedAt: 1_700_000_000_000,
54
- running: false,
55
- blank: false,
56
- }
57
-
58
- /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
59
- class FakeMux {
60
- listeners = new Set<(frame: unknown) => void>()
61
- onFrame(listener: (frame: unknown) => void): () => void {
62
- this.listeners.add(listener)
63
- return () => { this.listeners.delete(listener) }
64
- }
65
- emit(frame: unknown): void {
66
- for (const listener of this.listeners) listener(frame)
67
- }
68
- }
69
-
70
- /** One history entry wrapping a WireEvent (host history-page shape). */
71
- function makeEntry(type: string, data: unknown, seq: number): { event: WireEvent } {
72
- return { event: { type, seq, time: seq * 1_000, data } }
73
- }
74
-
75
- /** Build an empty history page (the live stream supplies all content). */
76
- function historyPage(): HistoryPage {
77
- return { events: [] as never, hasMore: false } as HistoryPage
78
- }
79
-
80
- /**
81
- * Emit one live text-delta chunk bound to the stable message id 'a-1' (the
82
- * mobile wire shape). A stable id keeps the message row mounted across the
83
- * fold, so the tests observe the throttle effect path only.
84
- */
85
- function chunk(mux: FakeMux, text: string, seq: number): void {
86
- mux.emit({
87
- type: 'session/event',
88
- sessionId: 's-1',
89
- event: makeEntry('assistant/chunk', { messageId: 'a-1', turn: 0, step: 0, text }, seq).event,
90
- })
91
- }
92
-
93
- /** Emit the final assistant message (authoritative text, closes the turn). */
94
- function finalMessage(mux: FakeMux, text: string, seq: number): void {
95
- mux.emit({
96
- type: 'session/event',
97
- sessionId: 's-1',
98
- event: makeEntry('assistant/message', {
99
- turn: 0,
100
- step: 0,
101
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text }] },
102
- }, seq).event,
103
- })
104
- }
105
-
106
- /** Round-trip HTML through the DOM so a byte comparison is symmetric. */
107
- function normalize(html: string): string {
108
- const element = document.createElement('div')
109
- element.innerHTML = html
110
- return element.innerHTML
111
- }
112
-
113
- /** The assistant message's markdown body (dangerouslySetInnerHTML target). */
114
- function body(): HTMLElement {
115
- const element = document.querySelector('.chat-msg.chat-msg-assistant .chat-md-body')
116
- if (element === null) throw new Error('assistant markdown body not found')
117
- return element as HTMLElement
118
- }
119
-
120
- beforeEach(() => {
121
- fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true })
122
- modelsMock.mockResolvedValue({
123
- current: { provider: 'fx', model: 'fx-1' },
124
- routable: true,
125
- groups: [],
126
- failures: [],
127
- } satisfies SessionModels)
128
- loadHistoryMock.mockResolvedValue(historyPage())
129
- vi.useFakeTimers()
130
- })
131
-
132
- afterEach(() => {
133
- cleanup()
134
- vi.useRealTimers()
135
- vi.clearAllMocks()
136
- vi.restoreAllMocks()
137
- })
138
-
139
- describe('ChatView streaming markdown throttle', () => {
140
- it('re-parses pending text at most once per interval and catches up byte-exactly', async () => {
141
- const mux = new FakeMux()
142
- await act(async () => {
143
- render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
144
- })
145
-
146
- const parts = [
147
- '第一段 **加粗** 文本与 `行内代码`。\n\n',
148
- '第二段 *斜体* 与 <标签> 内容。\n\n',
149
- '第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
150
- ]
151
- const full = parts.join('')
152
-
153
- // The first chunk mounts the pending message; the mount render parses it.
154
- await act(async () => { chunk(mux, parts[0] ?? '', 6) })
155
- const afterMount = renderMarkdownMock.mock.calls.length
156
- expect(afterMount).toBeGreaterThanOrEqual(1)
157
-
158
- // A burst of further chunks does not re-parse per chunk: at most one
159
- // leading parse may run (if the wall-clock gap to the burst exceeded the
160
- // window), but never one parse per chunk.
161
- await act(async () => {
162
- chunk(mux, parts[1] ?? '', 7)
163
- chunk(mux, parts[2] ?? '', 8)
164
- })
165
- expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(2)
166
- // During the window the last parsed result stays visible: the newest
167
- // chunk's text has not reached the DOM yet.
168
- expect(body().innerHTML).not.toContain('第三段')
169
-
170
- // The trailing render inside the window catches up to the newest text,
171
- // byte-identical to a full renderMarkdown of the accumulated text.
172
- await act(async () => {
173
- vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
174
- })
175
- expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
176
- expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(3)
177
- })
178
-
179
- it('renders the exact final HTML immediately when the turn closes', async () => {
180
- const mux = new FakeMux()
181
- await act(async () => {
182
- render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
183
- })
184
-
185
- // The final event carries the authoritative text and closes the turn
186
- // (pending -> false) in the same synchronous burst as the chunks.
187
- const finalText = '最终 **整篇** `渲染` 与 [链接](https://example.com/x) 文本。'
188
- await act(async () => {
189
- chunk(mux, '流式前缀', 6)
190
- chunk(mux, '中间过程', 7)
191
- finalMessage(mux, finalText, 8)
192
- })
193
-
194
- // No timer advance needed: the closed turn already renders the exact
195
- // final HTML (terminal messages keep the immediate-render behavior).
196
- expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(finalText)))
197
- })
198
-
199
- it('keeps the streaming render fresh after window edges and skips the identical final parse', async () => {
200
- const mux = new FakeMux()
201
- await act(async () => {
202
- render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
203
- })
204
-
205
- const first = '第一期内容。'
206
- const second = '第二期内容,包含 `code`。'
207
- const full = first + second
208
-
209
- // The chunk and the window edge in one tick: the trailing render fires
210
- // inside the window and shows the current text.
211
- await act(async () => {
212
- chunk(mux, first, 6)
213
- vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
214
- })
215
- expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(first)))
216
-
217
- // A later chunk schedules a new trailing render; before it fires the last
218
- // parsed result is still on screen.
219
- await act(async () => { chunk(mux, second, 7) })
220
- expect(body().innerHTML).not.toContain('第二期内容')
221
- await act(async () => {
222
- vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
223
- })
224
- expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
225
-
226
- // Closing the turn with the exact text already rendered must not re-parse
227
- // (the row already holds the byte-identical final HTML).
228
- const beforeFinal = renderMarkdownMock.mock.calls.length
229
- await act(async () => { finalMessage(mux, full, 9) })
230
- expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0)
231
- expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
232
- })
233
-
234
- it('does not collapse long text while streaming (pending) and collapses once turn finishes', async () => {
235
- const mux = new FakeMux()
236
- let container: HTMLElement
237
- await act(async () => {
238
- const rendered = render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
239
- container = rendered.container
240
- })
241
-
242
- const hugeChunk = 'C'.repeat(LONG_TEXT_LIMIT + 200)
243
-
244
- // Stream a chunk exceeding LONG_TEXT_LIMIT
245
- await act(async () => {
246
- chunk(mux, hugeChunk, 6)
247
- vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
248
- })
249
-
250
- // During streaming (pending), message is not collapsed
251
- expect(container!.querySelector('.chat-md-collapsed')).toBeNull()
252
- expect(screen.queryByRole('button', { name: /展开全文/ })).toBeNull()
253
-
254
- // Turn closes
255
- await act(async () => {
256
- finalMessage(mux, hugeChunk, 7)
257
- })
258
-
259
- // Once turn ends (not pending), message is collapsed and has expand button
260
- expect(container!.querySelector('.chat-md-collapsed')).not.toBeNull()
261
- expect(screen.getByRole('button', { name: new RegExp(`展开全文(${LONG_TEXT_LIMIT + 200} 字)`) })).toBeTruthy()
262
- })
263
- })