dsh-multi-chat 0.6.7 → 1.0.0
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.md +20 -21
- package/README.zh.md +20 -21
- package/bin/dsh-multi-chat.mjs +5 -9
- package/lib/client.js +778 -0
- package/lib/client.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/index.js +57 -69
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallToggle.d.ts +1 -1
- package/lib/types/client/WallToggle.d.ts.map +1 -0
- package/lib/types/client/WallToggle.js +36 -0
- package/lib/types/client/WallToggle.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallView.d.ts +1 -1
- package/lib/types/client/WallView.d.ts.map +1 -0
- package/lib/types/client/WallView.js +218 -0
- package/lib/types/client/WallView.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +59 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +96 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/client/store.js +27 -0
- package/lib/types/client/store.js.map +1 -0
- package/lib/types/client/wall-injected.d.ts.map +1 -0
- package/lib/types/client/wall-injected.js +76 -0
- package/lib/types/client/wall-injected.js.map +1 -0
- package/lib/types/gateway.d.ts +57 -0
- package/lib/types/gateway.d.ts.map +1 -0
- package/lib/types/gateway.js +566 -0
- package/lib/types/gateway.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/index.d.ts +1 -16
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +550 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +26 -0
- package/lib/types/invariant.js.map +1 -0
- package/package.json +31 -17
- package/scripts/install-plugin.ps1 +6 -9
- package/src/client/WallToggle.module.css +26 -0
- package/src/client/WallToggle.tsx +52 -0
- package/src/client/WallView.module.css +190 -0
- package/src/client/WallView.tsx +406 -0
- package/src/client/index.ts +103 -0
- package/src/client/locales.ts +100 -0
- package/src/client/store.ts +40 -0
- package/src/client/wall-injected.ts +123 -0
- package/src/css-modules.d.ts +5 -0
- package/src/gateway.ts +627 -0
- package/src/index.ts +618 -0
- package/src/invariant.ts +34 -0
- package/plugin/dsh-client-ui-multi-wall/README.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/README.zh.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/cordis.patch.yml +0 -7
- package/plugin/dsh-client-ui-multi-wall/lib/client.js +0 -781
- package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +0 -27
- package/plugin/dsh-client-ui-multi-wall/package.json +0 -88
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/index.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/locales.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/store.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/wall-injected.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/invariant.d.ts +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WallView: the multi-window wall as a `conversation.view` ring entry. When
|
|
3
|
+
* the header's "多窗口" tab is selected, the right panel swaps from the
|
|
4
|
+
* chat to this view — a toolbar plus a grid of iframes, one pane per running
|
|
5
|
+
* DSH instance (127.0.0.1:<port>). Pure additive UI; no existing slot is
|
|
6
|
+
* replaced.
|
|
7
|
+
*
|
|
8
|
+
* Recursion guard: panes embed `?multi-wall=embed` (the embedded page
|
|
9
|
+
* registers no wall UI). The serving instance is itself a pane, so the user
|
|
10
|
+
* can watch — or stop — the very instance they are in.
|
|
11
|
+
*
|
|
12
|
+
* Live data channels: the store owns ports/columns; discovery writes
|
|
13
|
+
* `setPorts` from /multi/api/ports (same-origin, served by the node half);
|
|
14
|
+
* per-pane liveness arrives from /multi/api/status polls. Components never
|
|
15
|
+
* see ctx — the fetch helpers are injected through the registration.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useRef, useState } from 'react'
|
|
18
|
+
import clsx from 'clsx'
|
|
19
|
+
import {
|
|
20
|
+
IconCloseOutline16, IconFullscreenOutline16, IconGlobeOutline14, IconPlusOutline16,
|
|
21
|
+
IconRefreshOutline14, IconRefreshOutline16, IconRightUpOutline16, IconStopFill16,
|
|
22
|
+
Button, Input, Menu, StateDot,
|
|
23
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
24
|
+
import type { PropsLocale, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
25
|
+
import type { HandleOf } from '@deepseek-ai/dsh-client-ui-slots'
|
|
26
|
+
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
27
|
+
import type { createWallStore } from './store.ts'
|
|
28
|
+
import type { WallInjected } from './wall-injected.ts'
|
|
29
|
+
import css from './WallView.module.css'
|
|
30
|
+
|
|
31
|
+
/** Composed props: the view-ring runtime share, the store, the probe face, and locale. */
|
|
32
|
+
export type WallViewProps =
|
|
33
|
+
& ConvViewProps
|
|
34
|
+
& PropsStore<HandleOf<typeof createWallStore>>
|
|
35
|
+
& WallInjected
|
|
36
|
+
& PropsLocale<'multiWall'>
|
|
37
|
+
|
|
38
|
+
/** Grid column presets, driven by the toolbar menu. 'auto' fills the row. */
|
|
39
|
+
const COLUMN_PRESETS = ['auto', '1', '2', '3', '4', '6'] as const
|
|
40
|
+
|
|
41
|
+
/** Embed flag appended to every pane URL; such pages register no wall UI. */
|
|
42
|
+
const EMBED_FLAG = 'multi-wall=embed'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether this wall is being viewed through the phone gateway (a non-loopback
|
|
46
|
+
* host) rather than on the machine running DSH. When remote, pane iframes must
|
|
47
|
+
* load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
|
|
48
|
+
* at the phone itself, not the host.
|
|
49
|
+
*/
|
|
50
|
+
function isRemoteViewer(): boolean {
|
|
51
|
+
const host = typeof window !== 'undefined' ? window.location.hostname : ''
|
|
52
|
+
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The iframe URL for a pane. Local viewers embed the loopback instance
|
|
57
|
+
* directly; remote (phone) viewers route through the gateway that is already
|
|
58
|
+
* serving this page, appending the port so the gateway proxies to it.
|
|
59
|
+
* @param port - target DSH instance port.
|
|
60
|
+
* @returns the pane URL.
|
|
61
|
+
*/
|
|
62
|
+
function paneUrl(port: number): string {
|
|
63
|
+
if (isRemoteViewer()) {
|
|
64
|
+
return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`
|
|
65
|
+
}
|
|
66
|
+
return `http://127.0.0.1:${port}/?${EMBED_FLAG}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
|
|
71
|
+
* the embedded original DSH UI.
|
|
72
|
+
*/
|
|
73
|
+
function WallPane(props: {
|
|
74
|
+
port: number
|
|
75
|
+
alive: boolean
|
|
76
|
+
zoomed: boolean
|
|
77
|
+
stopping: boolean
|
|
78
|
+
onZoom: () => void
|
|
79
|
+
onStop: () => void
|
|
80
|
+
onRemove: () => void
|
|
81
|
+
t: TranslateNS<'multiWall'>
|
|
82
|
+
}) {
|
|
83
|
+
const { port, alive, zoomed, stopping, onZoom, onStop, onRemove, t } = props
|
|
84
|
+
return (
|
|
85
|
+
<section className={clsx(css.pane, zoomed && css.zoomed)} data-port={port}>
|
|
86
|
+
<div className={css.paneHead}>
|
|
87
|
+
<StateDot state={alive ? 'done' : 'warning'} size={8} className={css.dot} />
|
|
88
|
+
<span className={css.paneTitle}>127.0.0.1:{port}</span>
|
|
89
|
+
<div className={css.paneActions}>
|
|
90
|
+
<button type="button" className={css.action} title={t('zoom')} onClick={onZoom}>
|
|
91
|
+
<IconFullscreenOutline16 size={14} />
|
|
92
|
+
</button>
|
|
93
|
+
<button type="button" className={css.action} title={t('reload')} onClick={(e) => {
|
|
94
|
+
e.currentTarget.closest('section')?.querySelector('iframe')?.contentWindow?.location.reload()
|
|
95
|
+
}}>
|
|
96
|
+
<IconRefreshOutline16 size={14} />
|
|
97
|
+
</button>
|
|
98
|
+
<button type="button" className={css.action} title={t('openTab')} onClick={() => {
|
|
99
|
+
window.open(paneUrl(port), '_blank')
|
|
100
|
+
}}>
|
|
101
|
+
<IconRightUpOutline16 size={14} />
|
|
102
|
+
</button>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={clsx(css.action, css.danger, stopping && css.confirm)}
|
|
106
|
+
title={t('stop')}
|
|
107
|
+
onClick={onStop}
|
|
108
|
+
>
|
|
109
|
+
{stopping ? t('stop.confirm') : <IconStopFill16 size={14} />}
|
|
110
|
+
</button>
|
|
111
|
+
<button type="button" className={css.action} title={t('remove')} onClick={onRemove}>
|
|
112
|
+
<IconCloseOutline16 size={14} />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div className={css.paneBody}>
|
|
117
|
+
<iframe
|
|
118
|
+
title={`DSH :${port}`}
|
|
119
|
+
src={paneUrl(port)}
|
|
120
|
+
loading="lazy"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render the wall: toolbar plus the horizontally-filled pane grid. Discovery
|
|
129
|
+
* runs on mount and liveness polls every 5s; the store's persisted ports
|
|
130
|
+
* survive view switches and reloads.
|
|
131
|
+
* @param props - composed slot props.
|
|
132
|
+
* @returns the wall surface.
|
|
133
|
+
*/
|
|
134
|
+
export function WallView({ useStore, actions, discover, probe, stop, create, link, t }: WallViewProps) {
|
|
135
|
+
const ports = useStore(s => s.ports)
|
|
136
|
+
const columns = useStore(s => s.columns)
|
|
137
|
+
const [alive, setAlive] = useState<Record<number, boolean>>({})
|
|
138
|
+
const [zoomedPort, setZoomedPort] = useState<number | null>(null)
|
|
139
|
+
const [confirmingStop, setConfirmingStop] = useState<number | null>(null)
|
|
140
|
+
const [scanFrom, setScanFrom] = useState(3070)
|
|
141
|
+
const [scanTo, setScanTo] = useState(3110)
|
|
142
|
+
const [colsMenuOpen, setColsMenuOpen] = useState(false)
|
|
143
|
+
const [creating, setCreating] = useState(false)
|
|
144
|
+
const [linkOpen, setLinkOpen] = useState(false)
|
|
145
|
+
const [linkInfo, setLinkInfo] = useState<Awaited<ReturnType<WallViewProps['link']>> | null>(null)
|
|
146
|
+
const [linkCopied, setLinkCopied] = useState(false)
|
|
147
|
+
const [status, setStatus] = useState('')
|
|
148
|
+
const aliveRef = useRef<Record<number, boolean>>({})
|
|
149
|
+
aliveRef.current = alive
|
|
150
|
+
|
|
151
|
+
// Discover on mount (the view renders only while selected), then poll
|
|
152
|
+
// liveness while the view stays mounted.
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
void discover().then(found => {
|
|
155
|
+
if (found.length > 0) actions.setPorts(found)
|
|
156
|
+
setStatus(found.length > 0 ? t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')) : '')
|
|
157
|
+
})
|
|
158
|
+
const timer = setInterval(() => {
|
|
159
|
+
if (ports.length === 0) return
|
|
160
|
+
void probe(ports).then(rows => {
|
|
161
|
+
const next: Record<number, boolean> = {}
|
|
162
|
+
for (const row of rows) next[row.port] = row.alive
|
|
163
|
+
setAlive(next)
|
|
164
|
+
})
|
|
165
|
+
}, 5000)
|
|
166
|
+
return () => { clearInterval(timer) }
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
}, [])
|
|
169
|
+
|
|
170
|
+
// Stop is destructive: the first click arms a per-pane confirm, the second
|
|
171
|
+
// executes it. Any other interaction clears the arm. The serving instance
|
|
172
|
+
// itself may be stopped too (the user may want to take this port down).
|
|
173
|
+
const handleStop = async (port: number) => {
|
|
174
|
+
if (confirmingStop !== port) {
|
|
175
|
+
setConfirmingStop(port)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
setConfirmingStop(null)
|
|
179
|
+
const result = await stop(port)
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
actions.removePort(port)
|
|
182
|
+
setAlive(current => ({ ...current, [port]: false }))
|
|
183
|
+
setStatus(t('stop.done').replace('{port}', String(port)))
|
|
184
|
+
} else {
|
|
185
|
+
setStatus(t('stop.failed').replace('{port}', String(port)).replace('{error}', result.error ?? ''))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const runDiscovery = async () => {
|
|
190
|
+
setStatus(t('status.scanning').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
|
|
191
|
+
const found = await discover()
|
|
192
|
+
if (found.length === 0) {
|
|
193
|
+
setStatus(t('status.none').replace('{from}', String(scanFrom)).replace('{to}', String(scanTo)))
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
actions.setPorts(found)
|
|
197
|
+
setStatus(t('status.found').replace('{count}', String(found.length)).replace('{ports}', found.join(', ')))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Start a brand-new DSH instance and add it to the wall. Failures always
|
|
201
|
+
// surface a concrete reason: the injected face reports HTTP status / body
|
|
202
|
+
// problems, and a thrown fetch surfaces its message — never a silent stall.
|
|
203
|
+
const handleCreate = async () => {
|
|
204
|
+
setCreating(true)
|
|
205
|
+
setStatus('')
|
|
206
|
+
try {
|
|
207
|
+
const result = await create()
|
|
208
|
+
if (result.ok && result.port !== undefined) {
|
|
209
|
+
actions.addPort(result.port)
|
|
210
|
+
setAlive(current => ({ ...current, [result.port!]: true }))
|
|
211
|
+
setStatus(t('create.done').replace('{port}', String(result.port)))
|
|
212
|
+
} else {
|
|
213
|
+
setStatus(t('create.failed').replace('{error}', result.error ?? t('create.unknown')))
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
setStatus(t('create.failed').replace('{error}', error instanceof Error ? error.message : String(error)))
|
|
217
|
+
} finally {
|
|
218
|
+
setCreating(false)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Leave the wall: switch the view ring back to the default chat view. The
|
|
223
|
+
// ring's active view lives in the chat store and only the header tab's
|
|
224
|
+
// click handler mutates it, so exit reuses that sanctioned path — the chat
|
|
225
|
+
// entry is always the first tab (order 0), which makes the click
|
|
226
|
+
// locale-independent. No tab (blank session) is a no-op.
|
|
227
|
+
const exitWall = () => {
|
|
228
|
+
const tab = document.querySelector<HTMLButtonElement>('[role="tablist"] [role="tab"]')
|
|
229
|
+
tab?.click()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Fetch the phone-reachable link(s) for this instance and open the sheet.
|
|
233
|
+
const handleLink = async () => {
|
|
234
|
+
if (linkInfo === null) {
|
|
235
|
+
setStatus(t('link.fetching'))
|
|
236
|
+
const info = await link()
|
|
237
|
+
setLinkInfo(info)
|
|
238
|
+
}
|
|
239
|
+
setLinkOpen(true)
|
|
240
|
+
setLinkCopied(false)
|
|
241
|
+
setStatus('')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Copy the first phone-reachable link to the clipboard. When the gateway
|
|
245
|
+
// issued a token it is appended as `?token=` so the phone bypasses the login
|
|
246
|
+
// form entirely (the gateway accepts a matching `?token=` query); the URL is
|
|
247
|
+
// otherwise the bare link. The standalone token line is still shown above so
|
|
248
|
+
// users who prefer typing it can.
|
|
249
|
+
const copyFirstLink = async () => {
|
|
250
|
+
if (linkInfo === null || linkInfo.lan.length === 0) return
|
|
251
|
+
try {
|
|
252
|
+
const base = linkInfo.lan[0] ?? ''
|
|
253
|
+
const token = linkInfo.token ?? ''
|
|
254
|
+
const url = token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
|
|
255
|
+
await navigator.clipboard.writeText(url)
|
|
256
|
+
setLinkCopied(true)
|
|
257
|
+
} catch {
|
|
258
|
+
setLinkCopied(false)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Every discovered port renders, including the one serving this wall: the
|
|
263
|
+
// user may want to watch (or stop) the instance they are in. Recursion is
|
|
264
|
+
// prevented by the embed flag, not by hiding the self pane.
|
|
265
|
+
const shown = ports
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className={css.wall} role="region" aria-label={t('overlay.title')} data-wall-view="">
|
|
269
|
+
<div className={css.toolbar}>
|
|
270
|
+
<span className={css.title}>{t('overlay.title')}</span>
|
|
271
|
+
<span className={css.status}>{status}</span>
|
|
272
|
+
<div className={css.controls}>
|
|
273
|
+
<label className={css.field}>{t('scan.from')}
|
|
274
|
+
<Input
|
|
275
|
+
type="number"
|
|
276
|
+
value={scanFrom}
|
|
277
|
+
onChange={e => setScanFrom(Number(e.target.value))}
|
|
278
|
+
/>
|
|
279
|
+
</label>
|
|
280
|
+
<label className={css.field}>{t('scan.to')}
|
|
281
|
+
<Input
|
|
282
|
+
type="number"
|
|
283
|
+
value={scanTo}
|
|
284
|
+
onChange={e => setScanTo(Number(e.target.value))}
|
|
285
|
+
/>
|
|
286
|
+
</label>
|
|
287
|
+
<Button variant="toolbar" size="sm" onClick={() => { void runDiscovery() }}>{t('scan')}</Button>
|
|
288
|
+
<Button
|
|
289
|
+
variant="toolbar"
|
|
290
|
+
size="sm"
|
|
291
|
+
icon={<IconPlusOutline16 size={14} />}
|
|
292
|
+
disabled={creating}
|
|
293
|
+
onClick={() => { void handleCreate() }}
|
|
294
|
+
>
|
|
295
|
+
{creating ? t('create.pending') : t('create')}
|
|
296
|
+
</Button>
|
|
297
|
+
<Menu
|
|
298
|
+
open={colsMenuOpen}
|
|
299
|
+
anchor={
|
|
300
|
+
<Button
|
|
301
|
+
variant="toolbar"
|
|
302
|
+
size="sm"
|
|
303
|
+
icon={<IconRightUpOutline16 size={14} />}
|
|
304
|
+
onClick={() => { setColsMenuOpen(true) }}
|
|
305
|
+
>
|
|
306
|
+
{columns === 'auto' ? t('columns.auto') : columns}
|
|
307
|
+
</Button>
|
|
308
|
+
}
|
|
309
|
+
items={COLUMN_PRESETS.map(c => ({
|
|
310
|
+
id: c,
|
|
311
|
+
label: c === 'auto' ? t('columns.auto') : c,
|
|
312
|
+
}))}
|
|
313
|
+
selectedId={columns}
|
|
314
|
+
onSelect={(id) => {
|
|
315
|
+
actions.setColumns(id)
|
|
316
|
+
setColsMenuOpen(false)
|
|
317
|
+
}}
|
|
318
|
+
onClose={() => { setColsMenuOpen(false) }}
|
|
319
|
+
compact
|
|
320
|
+
/>
|
|
321
|
+
<Button
|
|
322
|
+
variant="toolbar"
|
|
323
|
+
size="sm"
|
|
324
|
+
icon={<IconRefreshOutline14 size={14} />}
|
|
325
|
+
onClick={() => {
|
|
326
|
+
document.querySelectorAll(`.${css.paneBody} iframe`).forEach(f => {
|
|
327
|
+
(f as HTMLIFrameElement).contentWindow?.location.reload()
|
|
328
|
+
})
|
|
329
|
+
setStatus(t('status.refreshed'))
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
{t('refresh')}
|
|
333
|
+
</Button>
|
|
334
|
+
<Button
|
|
335
|
+
variant="toolbar"
|
|
336
|
+
size="sm"
|
|
337
|
+
icon={<IconGlobeOutline14 size={14} />}
|
|
338
|
+
aria-label={t('link.aria')}
|
|
339
|
+
onClick={() => { void handleLink() }}
|
|
340
|
+
>
|
|
341
|
+
{t('link')}
|
|
342
|
+
</Button>
|
|
343
|
+
<Button
|
|
344
|
+
variant="toolbar"
|
|
345
|
+
size="sm"
|
|
346
|
+
icon={<IconCloseOutline16 size={14} />}
|
|
347
|
+
aria-label={t('exit.aria')}
|
|
348
|
+
title={t('exit')}
|
|
349
|
+
onClick={() => { exitWall() }}
|
|
350
|
+
>
|
|
351
|
+
{t('exit')}
|
|
352
|
+
</Button>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
{linkOpen && linkInfo !== null && (
|
|
356
|
+
<div className={css.linkBar}>
|
|
357
|
+
{linkInfo.reachable
|
|
358
|
+
? (
|
|
359
|
+
<>
|
|
360
|
+
<span className={css.linkText}>
|
|
361
|
+
{t('link.reachable').replace('{urls}', linkInfo.lan.join(' '))}
|
|
362
|
+
{linkInfo.token !== undefined && linkInfo.token !== ''
|
|
363
|
+
? ` ${t('link.token').replace('{token}', linkInfo.token)}`
|
|
364
|
+
: ''}
|
|
365
|
+
</span>
|
|
366
|
+
{linkInfo.lan.length > 0 && (
|
|
367
|
+
<Button variant="outline" size="sm" onClick={() => { void copyFirstLink() }}>
|
|
368
|
+
{linkCopied ? t('link.copied') : t('link.copy')}
|
|
369
|
+
</Button>
|
|
370
|
+
)}
|
|
371
|
+
</>
|
|
372
|
+
)
|
|
373
|
+
: (
|
|
374
|
+
<span className={css.linkText}>
|
|
375
|
+
{t('link.unreachable').replace('{hint}', linkInfo.hint ?? '')}
|
|
376
|
+
</span>
|
|
377
|
+
)}
|
|
378
|
+
<Button variant="ghost" size="sm" icon={<IconCloseOutline16 size={14} />} onClick={() => { setLinkOpen(false) }}>
|
|
379
|
+
{t('overlay.close')}
|
|
380
|
+
</Button>
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
<div className={css.grid} data-cols={columns}>
|
|
384
|
+
{shown.map(port => (
|
|
385
|
+
<WallPane
|
|
386
|
+
key={port}
|
|
387
|
+
port={port}
|
|
388
|
+
alive={aliveRef.current[port] ?? true}
|
|
389
|
+
zoomed={zoomedPort === port}
|
|
390
|
+
stopping={confirmingStop === port}
|
|
391
|
+
onZoom={() => setZoomedPort(zoomedPort === port ? null : port)}
|
|
392
|
+
onStop={() => { void handleStop(port) }}
|
|
393
|
+
onRemove={() => { setConfirmingStop(null); actions.removePort(port) }}
|
|
394
|
+
t={t}
|
|
395
|
+
/>
|
|
396
|
+
))}
|
|
397
|
+
{shown.length === 0 && (
|
|
398
|
+
<div className={css.empty}>
|
|
399
|
+
<p>{t('empty')}</p>
|
|
400
|
+
<p className={css.hint}>{t('empty.hint')}</p>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-window wall plugin, browser half: contributes the wall as a
|
|
3
|
+
* `conversation.view` ring entry (the official replace-the-chat-surface
|
|
4
|
+
* mechanism — the header grows a "多窗口墙" tab and the right panel swaps to
|
|
5
|
+
* the wall) plus a sidebar footer shortcut that jumps to that view. No
|
|
6
|
+
* existing slot is replaced and no original interaction logic changes; the
|
|
7
|
+
* wall is pure UI over the node half's /multi/api probe routes.
|
|
8
|
+
*
|
|
9
|
+
* Recursion guard: a wall pane embeds each instance as
|
|
10
|
+
* `http://127.0.0.1:<port>/?multi-wall=embed`; the embedded page sees the
|
|
11
|
+
* query flag and registers nothing, so a pane can never grow a wall of its
|
|
12
|
+
* own. The wall also never renders the port it is served on (SELF_PORT is
|
|
13
|
+
* filtered at render time and by the node half's discovery).
|
|
14
|
+
*/
|
|
15
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
16
|
+
// Type-only: pulls the layout-owned slot declarations (sidebar footer,
|
|
17
|
+
// shell.overlay) into this program.
|
|
18
|
+
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
19
|
+
// Type-only: pulls the conversation package's view-ring declaration
|
|
20
|
+
// ('conversation.view') into this program.
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
22
|
+
// Type-only: pulls the sidebar footer action owner props into this program.
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
24
|
+
// Type-only: pulls ctx.locale into this program.
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
26
|
+
import { en, zh, type MultiWallKey } from './locales.ts'
|
|
27
|
+
import { createWallStore } from './store.ts'
|
|
28
|
+
import { createWallInjected } from './wall-injected.ts'
|
|
29
|
+
import { WallView } from './WallView.tsx'
|
|
30
|
+
import { WallToggle } from './WallToggle.tsx'
|
|
31
|
+
|
|
32
|
+
export { WallView } from './WallView.tsx'
|
|
33
|
+
export type { WallViewProps } from './WallView.tsx'
|
|
34
|
+
export { WallToggle } from './WallToggle.tsx'
|
|
35
|
+
export type { WallToggleProps } from './WallToggle.tsx'
|
|
36
|
+
export type { MultiWallKey } from './locales.ts'
|
|
37
|
+
export { createWallStore } from './store.ts'
|
|
38
|
+
export type { WallState, WallActions } from './store.ts'
|
|
39
|
+
export type { ProbeRow, StopRow, WallInjected } from './wall-injected.ts'
|
|
40
|
+
|
|
41
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
42
|
+
interface LocaleNamespaceMap {
|
|
43
|
+
/** Multi-window wall copy. */
|
|
44
|
+
multiWall: MultiWallKey
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Dictionary namespace owned by this plugin. */
|
|
49
|
+
const NS = 'multiWall'
|
|
50
|
+
|
|
51
|
+
/** Required services: slots for both registrations, locale for copy. */
|
|
52
|
+
export const inject = ['slots', 'locale']
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this page is an embedded wall pane. Panes load
|
|
56
|
+
* `?multi-wall=embed`; such pages register no wall UI at all, which stops a
|
|
57
|
+
* wall inside a wall (the pane would otherwise recursively embed the
|
|
58
|
+
* serving instance).
|
|
59
|
+
* @returns true when the query flag is present.
|
|
60
|
+
*/
|
|
61
|
+
function isEmbeddedPane(): boolean {
|
|
62
|
+
return new URLSearchParams(window.location.search).has('multi-wall')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Client plugin body: register the dictionaries, the view-ring entry (the
|
|
67
|
+
* wall), and the sidebar footer shortcut. Embedded panes register nothing.
|
|
68
|
+
* @param ctx - client root context.
|
|
69
|
+
*/
|
|
70
|
+
export function apply(ctx: ClientContext): void {
|
|
71
|
+
if (isEmbeddedPane()) return
|
|
72
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-multi-wall: dictionaries')
|
|
73
|
+
|
|
74
|
+
// Registration-time text (the view tab label) reads through the bound
|
|
75
|
+
// translate as a thunk, so it follows the active locale without
|
|
76
|
+
// re-registration.
|
|
77
|
+
const t = ctx.locale.bind(NS)
|
|
78
|
+
|
|
79
|
+
// One wall store handle rides both registrations (ports, columns).
|
|
80
|
+
const wallStore = createWallStore()
|
|
81
|
+
|
|
82
|
+
// The wall itself: a 'conversation.view' ring entry. The header projects
|
|
83
|
+
// the tab from the registration options; selecting it swaps the right
|
|
84
|
+
// panel from the chat to the wall (official view-ring behavior).
|
|
85
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
86
|
+
name: 'conversation.view',
|
|
87
|
+
id: 'multi-wall',
|
|
88
|
+
order: 20,
|
|
89
|
+
label: () => t('view.multiWall'),
|
|
90
|
+
locale: NS,
|
|
91
|
+
store: wallStore,
|
|
92
|
+
inject: () => createWallInjected(),
|
|
93
|
+
}, WallView))
|
|
94
|
+
|
|
95
|
+
// The sidebar footer shortcut: jumps to the wall view. It owns no state —
|
|
96
|
+
// the view ring decides what renders — so it is a plain action row.
|
|
97
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
98
|
+
name: 'sidebar.footer.action',
|
|
99
|
+
id: 'multi-wall',
|
|
100
|
+
order: 10,
|
|
101
|
+
locale: NS,
|
|
102
|
+
}, WallToggle))
|
|
103
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** `multiWall` namespace dictionaries. */
|
|
2
|
+
|
|
3
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
4
|
+
export const zh = {
|
|
5
|
+
'toggle': '多窗口',
|
|
6
|
+
'toggle.aria': '打开或关闭多窗口',
|
|
7
|
+
'view.multiWall': '多窗口',
|
|
8
|
+
'overlay.title': '多窗口',
|
|
9
|
+
'overlay.close': '关闭',
|
|
10
|
+
'scan': '发现实例',
|
|
11
|
+
'scan.from': '起始端口',
|
|
12
|
+
'scan.to': '结束端口',
|
|
13
|
+
'create': '新建窗口',
|
|
14
|
+
'create.pending': '启动中…',
|
|
15
|
+
'create.done': '已创建窗口 :{port}',
|
|
16
|
+
'create.failed': '创建失败:{error}',
|
|
17
|
+
'create.unknown': '未知原因',
|
|
18
|
+
'exit': '退出',
|
|
19
|
+
'exit.aria': '退出多窗口,返回对话',
|
|
20
|
+
'link': '手机访问',
|
|
21
|
+
'link.aria': '获取手机访问链接',
|
|
22
|
+
'link.fetching': '正在获取链接…',
|
|
23
|
+
'link.none': '未检测到局域网地址',
|
|
24
|
+
'link.copy': '复制链接',
|
|
25
|
+
'link.copied': '已复制',
|
|
26
|
+
'link.hint': '提示:{hint}',
|
|
27
|
+
'link.reachable': '手机在同一网络时可用:{urls}',
|
|
28
|
+
'link.unreachable': '当前实例仅绑定本机,手机无法访问。{hint}',
|
|
29
|
+
'link.token': '口令:{token}',
|
|
30
|
+
'columns': '列数',
|
|
31
|
+
'columns.auto': '自动',
|
|
32
|
+
'refresh': '全部刷新',
|
|
33
|
+
'openTab': '新标签页打开',
|
|
34
|
+
'reload': '重新加载',
|
|
35
|
+
'remove': '从视图移除',
|
|
36
|
+
'stop': '关闭实例',
|
|
37
|
+
'stop.confirm': '确定关闭?',
|
|
38
|
+
'stop.self': '即将关闭当前实例(本页面将断开)',
|
|
39
|
+
'stop.done': '已关闭 :{port}',
|
|
40
|
+
'stop.failed': '关闭 :{port} 失败:{error}',
|
|
41
|
+
'zoom': '放大',
|
|
42
|
+
'loading': '加载中',
|
|
43
|
+
'empty': '没有检测到 DSH 实例',
|
|
44
|
+
'empty.hint': '先启动若干 dsh web --port <n> 实例,再点击「发现实例」或手动添加端口。',
|
|
45
|
+
'status.scanning': '扫描 {from}–{to} …',
|
|
46
|
+
'status.found': '发现 {count} 个实例:{ports}',
|
|
47
|
+
'status.none': '区间 {from}–{to} 未发现 DSH 实例',
|
|
48
|
+
'status.refreshed': '已刷新全部窗口',
|
|
49
|
+
} satisfies Record<string, string>
|
|
50
|
+
|
|
51
|
+
/** The multi-wall namespace key union. */
|
|
52
|
+
export type MultiWallKey = keyof typeof zh
|
|
53
|
+
|
|
54
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
55
|
+
export const en = {
|
|
56
|
+
'toggle': 'Multi-Window',
|
|
57
|
+
'toggle.aria': 'Toggle multi-window',
|
|
58
|
+
'view.multiWall': 'Multi-Window',
|
|
59
|
+
'overlay.title': 'Multi-Window',
|
|
60
|
+
'overlay.close': 'Close',
|
|
61
|
+
'scan': 'Discover',
|
|
62
|
+
'scan.from': 'Start port',
|
|
63
|
+
'scan.to': 'End port',
|
|
64
|
+
'create': 'New window',
|
|
65
|
+
'create.pending': 'Starting…',
|
|
66
|
+
'create.done': 'Created window :{port}',
|
|
67
|
+
'create.failed': 'Create failed: {error}',
|
|
68
|
+
'create.unknown': 'unknown reason',
|
|
69
|
+
'exit': 'Exit',
|
|
70
|
+
'exit.aria': 'Exit multi-window and return to chat',
|
|
71
|
+
'link': 'Phone access',
|
|
72
|
+
'link.aria': 'Get phone access link',
|
|
73
|
+
'link.fetching': 'Fetching link…',
|
|
74
|
+
'link.none': 'No LAN address detected',
|
|
75
|
+
'link.copy': 'Copy link',
|
|
76
|
+
'link.copied': 'Copied',
|
|
77
|
+
'link.hint': 'Hint: {hint}',
|
|
78
|
+
'link.reachable': 'Reachable on the same network: {urls}',
|
|
79
|
+
'link.unreachable': 'This instance binds loopback only; phones cannot reach it. {hint}',
|
|
80
|
+
'link.token': 'Token: {token}',
|
|
81
|
+
'columns': 'Columns',
|
|
82
|
+
'columns.auto': 'Auto',
|
|
83
|
+
'refresh': 'Refresh all',
|
|
84
|
+
'openTab': 'Open in new tab',
|
|
85
|
+
'reload': 'Reload',
|
|
86
|
+
'remove': 'Remove from view',
|
|
87
|
+
'stop': 'Stop instance',
|
|
88
|
+
'stop.confirm': 'Confirm stop?',
|
|
89
|
+
'stop.self': 'Stopping the current instance (this page will disconnect)',
|
|
90
|
+
'stop.done': 'Stopped :{port}',
|
|
91
|
+
'stop.failed': 'Failed to stop :{port}: {error}',
|
|
92
|
+
'zoom': 'Zoom',
|
|
93
|
+
'loading': 'Loading',
|
|
94
|
+
'empty': 'No DSH instances found',
|
|
95
|
+
'empty.hint': 'Start a few `dsh web --port <n>` instances first, then click "Discover" or add a port manually.',
|
|
96
|
+
'status.scanning': 'Scanning {from}–{to} …',
|
|
97
|
+
'status.found': 'Found {count} instance(s): {ports}',
|
|
98
|
+
'status.none': 'No DSH instances in {from}–{to}',
|
|
99
|
+
'status.refreshed': 'Refreshed all windows',
|
|
100
|
+
} satisfies Record<MultiWallKey, string>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall store: which instances are shown and the grid columns. The ports list
|
|
3
|
+
* is the wall's whole business state — discovery writes it, removal filters
|
|
4
|
+
* it, and the grid renders from it. Open/closed is NOT stored here: the wall
|
|
5
|
+
* is a `conversation.view` ring entry, so the active view (chat store's
|
|
6
|
+
* `view` field) decides whether it renders.
|
|
7
|
+
*/
|
|
8
|
+
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
|
9
|
+
|
|
10
|
+
/** Wall viewing state: displayed ports and grid columns. */
|
|
11
|
+
export type WallState = {
|
|
12
|
+
ports: number[]
|
|
13
|
+
columns: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The wall store's complete write set. */
|
|
17
|
+
export type WallActions = {
|
|
18
|
+
setPorts: (draft: WallState, ports: number[]) => void
|
|
19
|
+
addPort: (draft: WallState, port: number) => void
|
|
20
|
+
removePort: (draft: WallState, port: number) => void
|
|
21
|
+
setColumns: (draft: WallState, columns: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create the wall store handle. Persisted under `dsh.multi-wall` so the port
|
|
26
|
+
* set and column choice survive view switches and reloads.
|
|
27
|
+
* @returns the store handle (spec + type + identity + factory in one).
|
|
28
|
+
*/
|
|
29
|
+
export function createWallStore(): EngineStoreHandle<WallState, WallActions> {
|
|
30
|
+
return defineStore({
|
|
31
|
+
init: (): WallState => ({ ports: [], columns: 'auto' }),
|
|
32
|
+
persist: 'dsh.multi-wall',
|
|
33
|
+
actions: {
|
|
34
|
+
setPorts: (d, ports: number[]) => { d.ports = ports },
|
|
35
|
+
addPort: (d, port: number) => { if (!d.ports.includes(port)) d.ports = [...d.ports, port] },
|
|
36
|
+
removePort: (d, port: number) => { d.ports = d.ports.filter(p => p !== port) },
|
|
37
|
+
setColumns: (d, columns: string) => { d.columns = columns },
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
}
|