@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/views/App.tsx
DELETED
|
@@ -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
|
-
})
|