@linxin666/dsh-remote-web-ui 0.2.0 → 0.2.1
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 +15 -7
- package/README.zh.md +4 -4
- package/lib/client.js +235 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +26 -7
- package/lib/mobile.js +209 -39
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +142 -3
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +11 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +2 -0
- package/lib/types/mobile/api.d.ts +10 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +4 -0
- package/lib/types/mobile/messages.d.ts +24 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +60 -23
- package/lib/types/mobile/messages.test.js +49 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +38 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +34 -11
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +30 -4
- package/lib/types/mobile/views/SessionListView.test.js +58 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +4 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +10 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -2
- package/lib/types/tunnel.d.ts +1 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -2
- package/lib/types/update.d.ts +1 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +1 -1
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/RemoteEntry.tsx +10 -0
- package/src/client/RemoteSettingsCard.tsx +2 -2
- package/src/client/UpdatePanel.tsx +2 -0
- package/src/client/locales.ts +2 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/gate.ts +2 -0
- package/src/mobile/api.ts +14 -1
- package/src/mobile/messages.test.ts +52 -1
- package/src/mobile/messages.ts +65 -22
- package/src/mobile/mobile-styles.ts +38 -0
- package/src/mobile/views/ChatView.tsx +33 -10
- package/src/mobile/views/SessionListView.test.tsx +69 -1
- package/src/mobile/views/SessionListView.tsx +58 -5
- package/src/mobile/views/WorkspaceView.tsx +4 -2
- package/src/mobile-api.ts +9 -1
- package/src/routes.ts +22 -2
- package/src/tunnel.ts +7 -2
- package/src/update.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** foldEvents: message-list folding from a session event stream. */
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { foldEvents, type WireEvent } from './messages.ts'
|
|
3
|
+
import { EventFolder, foldEvents, type WireEvent } from './messages.ts'
|
|
4
4
|
|
|
5
5
|
/** Assemble one event with an auto-incrementing seq / time. */
|
|
6
6
|
function makeEvent(
|
|
@@ -326,3 +326,54 @@ describe('foldEvents', () => {
|
|
|
326
326
|
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
|
|
327
327
|
})
|
|
328
328
|
})
|
|
329
|
+
|
|
330
|
+
describe('EventFolder incremental folding', () => {
|
|
331
|
+
it('matches a one-shot fold when events arrive one at a time', () => {
|
|
332
|
+
const stream: WireEvent[] = [
|
|
333
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
334
|
+
textChunk(0, 0, '你', 1),
|
|
335
|
+
textChunk(0, 0, '好', 2),
|
|
336
|
+
makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, '你好'), 3),
|
|
337
|
+
makeEvent('message/update', { id: 'u-1', text: '更新' }, 4),
|
|
338
|
+
]
|
|
339
|
+
const oneShot = foldEvents(stream)
|
|
340
|
+
const folder = new EventFolder()
|
|
341
|
+
let incremental: ReturnType<typeof foldEvents> = []
|
|
342
|
+
for (const event of stream) incremental = folder.fold([event])
|
|
343
|
+
expect(incremental).toEqual(oneShot)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('replays and re-folds are no-ops and reuse the previous snapshot identity', () => {
|
|
347
|
+
const folder = new EventFolder(foldEvents([
|
|
348
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
349
|
+
textChunk(0, 0, '你', 1),
|
|
350
|
+
]))
|
|
351
|
+
const first = folder.fold([textChunk(0, 0, '好', 2)])
|
|
352
|
+
expect(first[first.length - 1]).toMatchObject({ text: '你好', pending: true })
|
|
353
|
+
// Same event again (wire replay or a double-invoked state updater): no change, same identity.
|
|
354
|
+
const replay = folder.fold([textChunk(0, 0, '好', 2)])
|
|
355
|
+
expect(replay).toBe(first)
|
|
356
|
+
// A no-op batch over an already folded stream also keeps the identity.
|
|
357
|
+
expect(folder.fold([])).toBe(first)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('prepends older pages and keeps folding live events on top', () => {
|
|
361
|
+
const folder = new EventFolder(foldEvents([
|
|
362
|
+
makeEvent('user/message', userMessageData('u-2', '第二页'), 10),
|
|
363
|
+
]))
|
|
364
|
+
folder.prepend(foldEvents([makeEvent('user/message', userMessageData('u-1', '第一页'), 5)]))
|
|
365
|
+
const withLive = folder.fold([textChunk(0, 0, '新', 11)])
|
|
366
|
+
expect(withLive.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
367
|
+
// The live fold must not lose the prepended rows on later events.
|
|
368
|
+
const later = folder.fold([textChunk(0, 0, '续', 12)])
|
|
369
|
+
expect(later.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
370
|
+
expect(later[2]).toMatchObject({ text: '新续', pending: true })
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('seed replaces the whole stream', () => {
|
|
374
|
+
const folder = new EventFolder(foldEvents([makeEvent('user/message', userMessageData('u-1', '旧'), 0)]))
|
|
375
|
+
folder.seed(foldEvents([makeEvent('user/message', userMessageData('u-2', '新'), 5)]))
|
|
376
|
+
expect(folder.snapshot().map(message => message.id)).toEqual(['u-2'])
|
|
377
|
+
expect(folder.fold([textChunk(0, 0, '追加', 6)]).map(message => message.id)).toEqual(['u-2', 'assistant,0.0#6'])
|
|
378
|
+
})
|
|
379
|
+
})
|
package/src/mobile/messages.ts
CHANGED
|
@@ -191,16 +191,6 @@ function chunkTarget(data: unknown): { text: string; kind: 'text' | 'reasoning';
|
|
|
191
191
|
return result
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/** Max message seq across an existing list, or -1 for an empty list. */
|
|
195
|
-
function watermarkOf(existing: readonly RenderMessage[] | undefined): number {
|
|
196
|
-
if (existing === undefined) return -1
|
|
197
|
-
let maxSeq = -1
|
|
198
|
-
for (const message of existing) {
|
|
199
|
-
if (message.seq > maxSeq) maxSeq = message.seq
|
|
200
|
-
}
|
|
201
|
-
return maxSeq
|
|
202
|
-
}
|
|
203
|
-
|
|
204
194
|
/** Mutable fold state; message objects are immutable and swapped on change. */
|
|
205
195
|
interface FoldState {
|
|
206
196
|
messages: RenderMessage[]
|
|
@@ -215,6 +205,8 @@ interface FoldState {
|
|
|
215
205
|
toolNames: Map<string, Set<string>>
|
|
216
206
|
/** Context window for the current model (from request/context). */
|
|
217
207
|
contextWindow?: number
|
|
208
|
+
/** Highest seq folded so far; the replay/watermark gate. */
|
|
209
|
+
maxSeq: number
|
|
218
210
|
}
|
|
219
211
|
|
|
220
212
|
function createState(existing: readonly RenderMessage[] | undefined): FoldState {
|
|
@@ -226,8 +218,10 @@ function createState(existing: readonly RenderMessage[] | undefined): FoldState
|
|
|
226
218
|
turnStepMessage: new Map(),
|
|
227
219
|
messageTurn: new Map(),
|
|
228
220
|
toolNames: new Map(),
|
|
221
|
+
maxSeq: -1,
|
|
229
222
|
}
|
|
230
223
|
for (const message of messages) {
|
|
224
|
+
if (message.seq > state.maxSeq) state.maxSeq = message.seq
|
|
231
225
|
state.byId.set(message.id, message)
|
|
232
226
|
if (message.kind !== 'assistant') continue
|
|
233
227
|
// Rebuild the (turn, step) and turn index maps lost when `existing` was
|
|
@@ -287,6 +281,7 @@ function retargetTurnStep(state: FoldState, key: string | undefined, oldMessage:
|
|
|
287
281
|
|
|
288
282
|
/** Fold one event into the working state. Assumes the event passes the watermark. */
|
|
289
283
|
function applyEvent(state: FoldState, event: WireEvent): void {
|
|
284
|
+
if (event.seq > state.maxSeq) state.maxSeq = event.seq
|
|
290
285
|
switch (event.type) {
|
|
291
286
|
case 'user/message':
|
|
292
287
|
applyUserMessage(state, event)
|
|
@@ -608,17 +603,65 @@ function applyTurnEnd(state: FoldState, event: WireEvent): void {
|
|
|
608
603
|
* @returns messages sorted by seq.
|
|
609
604
|
*/
|
|
610
605
|
export function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[] {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
606
|
+
return new EventFolder(existing).fold(events)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Incremental folder for one message stream. Live chat folds one event at a
|
|
611
|
+
* time; rebuilding the five index maps by scanning every message per event
|
|
612
|
+
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
613
|
+
* indexes alive across folds, applies each event in O(1) map operations, and
|
|
614
|
+
* returns the previous snapshot identity unchanged when nothing applied, so
|
|
615
|
+
* React skips the re-render entirely. Replayed events are no-ops: the maxSeq
|
|
616
|
+
* watermark advanced by the first application skips them, which also makes a
|
|
617
|
+
* double-invoked React state updater harmless.
|
|
618
|
+
*/
|
|
619
|
+
export class EventFolder {
|
|
620
|
+
private state: FoldState
|
|
621
|
+
private snapshotList: RenderMessage[] | undefined
|
|
622
|
+
|
|
623
|
+
/** @param initial - seed rows (history tail load); omit for an empty stream. */
|
|
624
|
+
constructor(initial?: readonly RenderMessage[]) {
|
|
625
|
+
this.state = createState(initial)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Fold one batch incrementally; returns the current snapshot list. */
|
|
629
|
+
fold(events: readonly WireEvent[]): RenderMessage[] {
|
|
630
|
+
const sorted = [...events].sort((a, b) => a.seq - b.seq)
|
|
631
|
+
let applied = false
|
|
632
|
+
for (const event of sorted) {
|
|
633
|
+
if (event.seq <= this.state.maxSeq) continue
|
|
634
|
+
applyEvent(this.state, event)
|
|
635
|
+
applied = true
|
|
636
|
+
}
|
|
637
|
+
if (!applied && this.snapshotList !== undefined) return this.snapshotList
|
|
638
|
+
this.snapshotList = snapshotOf(this.state)
|
|
639
|
+
return this.snapshotList
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Replace the whole stream (history reload / session switch). */
|
|
643
|
+
seed(messages: readonly RenderMessage[]): void {
|
|
644
|
+
this.state = createState(messages)
|
|
645
|
+
this.snapshotList = undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Prepend an older history page (exact seam; no overlapping seqs). */
|
|
649
|
+
prepend(older: readonly RenderMessage[]): void {
|
|
650
|
+
this.state = createState([...older, ...this.state.messages])
|
|
651
|
+
this.snapshotList = undefined
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Current snapshot list; a fresh copy whenever the folder changed. */
|
|
655
|
+
snapshot(): RenderMessage[] {
|
|
656
|
+
if (this.snapshotList !== undefined) return this.snapshotList
|
|
657
|
+
this.snapshotList = snapshotOf(this.state)
|
|
658
|
+
return this.snapshotList
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
|
|
663
|
+
function snapshotOf(state: FoldState): RenderMessage[] {
|
|
664
|
+
const out = [...state.messages]
|
|
622
665
|
let ordered = true
|
|
623
666
|
for (let index = 1; index < out.length; index += 1) {
|
|
624
667
|
const prev = out[index - 1]!
|
|
@@ -628,5 +671,5 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
|
|
|
628
671
|
break
|
|
629
672
|
}
|
|
630
673
|
}
|
|
631
|
-
return ordered ? out :
|
|
674
|
+
return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
|
|
632
675
|
}
|
|
@@ -252,6 +252,43 @@ body {
|
|
|
252
252
|
padding: 8px 16px;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
.mobile-create {
|
|
256
|
+
display: grid;
|
|
257
|
+
gap: 8px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.mobile-preset {
|
|
261
|
+
display: grid;
|
|
262
|
+
gap: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.mobile-presetLabel {
|
|
266
|
+
color: var(--m-text-secondary);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.mobile-presetSelect {
|
|
272
|
+
width: 100%;
|
|
273
|
+
height: 40px;
|
|
274
|
+
padding: 0 36px 0 12px;
|
|
275
|
+
border: 1px solid var(--m-border);
|
|
276
|
+
border-radius: 10px;
|
|
277
|
+
background: var(--m-bg-input);
|
|
278
|
+
color: var(--m-text);
|
|
279
|
+
font: inherit;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.mobile-presetSelect:disabled {
|
|
283
|
+
opacity: 0.6;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.mobile-presetDescription {
|
|
287
|
+
margin: 0;
|
|
288
|
+
color: var(--m-text-tertiary);
|
|
289
|
+
font-size: 12px;
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
/* ── buttons ─────────────────────────────────────────────────────────── */
|
|
256
293
|
|
|
257
294
|
.mobile-button {
|
|
@@ -896,6 +933,7 @@ body {
|
|
|
896
933
|
.mobile-row:focus-visible,
|
|
897
934
|
.mobile-button:focus-visible,
|
|
898
935
|
.mobile-new:focus-visible,
|
|
936
|
+
.mobile-presetSelect:focus-visible,
|
|
899
937
|
.chat-send:focus-visible,
|
|
900
938
|
.chat-chip:focus-visible,
|
|
901
939
|
.chat-msg-toggle:focus-visible,
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* permission pickers, both as bottom sheets.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
14
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
15
15
|
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
16
16
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
17
|
import { loadHistory, prompt, type SessionView } from './App.tsx'
|
|
18
18
|
import { errorText, formatTime, staleHostHint } from './App.tsx'
|
|
19
19
|
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
20
|
-
import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
20
|
+
import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
21
21
|
import { renderMarkdown } from '../markdown.ts'
|
|
22
22
|
import { MuxClient } from '../mux.ts'
|
|
23
23
|
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
@@ -144,6 +144,8 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
144
144
|
const tailLoadingRef = useRef(true)
|
|
145
145
|
/** Live session events buffered while the initial tail page loads. */
|
|
146
146
|
const liveBufferRef = useRef<WireEvent[]>([])
|
|
147
|
+
/** Incremental folder for this session's stream (indexes stay hot across events). */
|
|
148
|
+
const folderRef = useRef<EventFolder | undefined>(undefined)
|
|
147
149
|
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
148
150
|
const liveBufferOverflowRef = useRef(false)
|
|
149
151
|
|
|
@@ -188,6 +190,7 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
188
190
|
tailLoadingRef.current = true
|
|
189
191
|
liveBufferRef.current = []
|
|
190
192
|
liveBufferOverflowRef.current = false
|
|
193
|
+
folderRef.current = undefined
|
|
191
194
|
setLoading(true)
|
|
192
195
|
setError(undefined)
|
|
193
196
|
setMessages([])
|
|
@@ -199,7 +202,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
199
202
|
const buffered = liveBufferRef.current
|
|
200
203
|
liveBufferRef.current = []
|
|
201
204
|
tailLoadingRef.current = false
|
|
202
|
-
|
|
205
|
+
const folder = new EventFolder(foldEvents(page.events.map(eventOf)))
|
|
206
|
+
folderRef.current = folder
|
|
207
|
+
setMessages(folder.fold(buffered))
|
|
203
208
|
setHasOlder(page.hasMore)
|
|
204
209
|
setLoading(false)
|
|
205
210
|
// The history-tail projection baseline seeds the permission picker.
|
|
@@ -215,7 +220,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
215
220
|
void loadHistory(session.sessionId, undefined, controller.signal).then(
|
|
216
221
|
(fresh) => {
|
|
217
222
|
if (cancelled) return
|
|
218
|
-
|
|
223
|
+
const folder = folderRef.current
|
|
224
|
+
const freshEvents = fresh.events.map(eventOf)
|
|
225
|
+
setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents))
|
|
219
226
|
liveBufferOverflowRef.current = false
|
|
220
227
|
},
|
|
221
228
|
(reason: unknown) => {
|
|
@@ -231,7 +238,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
231
238
|
const buffered = liveBufferRef.current
|
|
232
239
|
liveBufferRef.current = []
|
|
233
240
|
tailLoadingRef.current = false
|
|
234
|
-
if (buffered.length > 0)
|
|
241
|
+
if (buffered.length > 0) {
|
|
242
|
+
const folder = folderRef.current
|
|
243
|
+
setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered))
|
|
244
|
+
}
|
|
235
245
|
setError(errorText(reason))
|
|
236
246
|
setLoading(false)
|
|
237
247
|
},
|
|
@@ -274,7 +284,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
274
284
|
liveBufferRef.current.push(event)
|
|
275
285
|
return
|
|
276
286
|
}
|
|
277
|
-
setMessages(previous =>
|
|
287
|
+
setMessages(previous => {
|
|
288
|
+
const folder = folderRef.current
|
|
289
|
+
return folder === undefined ? foldEvents([event], previous) : folder.fold([event])
|
|
290
|
+
})
|
|
278
291
|
return
|
|
279
292
|
}
|
|
280
293
|
// Live projection pushes keep the permission picker current.
|
|
@@ -330,7 +343,13 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
330
343
|
pendingRef.current = false
|
|
331
344
|
setLoading(false)
|
|
332
345
|
const older = foldEvents(page.events.map(eventOf))
|
|
333
|
-
|
|
346
|
+
const folder = folderRef.current
|
|
347
|
+
if (folder === undefined) {
|
|
348
|
+
setMessages(previous => [...older, ...previous])
|
|
349
|
+
} else {
|
|
350
|
+
folder.prepend(older)
|
|
351
|
+
setMessages(folder.snapshot())
|
|
352
|
+
}
|
|
334
353
|
setHasOlder(page.hasMore)
|
|
335
354
|
},
|
|
336
355
|
(reason: unknown) => {
|
|
@@ -481,8 +500,12 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
481
500
|
|
|
482
501
|
/* ── message rows ─────────────────────────────────────────────────────── */
|
|
483
502
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
503
|
+
/**
|
|
504
|
+
* One rendered message row (user bubble or assistant bubble with folds).
|
|
505
|
+
* Memoized: live streaming updates exactly one message object per frame, so
|
|
506
|
+
* unchanged rows skip re-rendering their markdown/sub-components.
|
|
507
|
+
*/
|
|
508
|
+
const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }: {
|
|
486
509
|
message: RenderMessage
|
|
487
510
|
showToolCalls: boolean
|
|
488
511
|
showSystemMessages: boolean
|
|
@@ -510,7 +533,7 @@ function MessageRow({ message, showToolCalls, showSystemMessages }: {
|
|
|
510
533
|
<span className="chat-msg-time">{formatTime(message.time)}</span>
|
|
511
534
|
</div>
|
|
512
535
|
)
|
|
513
|
-
}
|
|
536
|
+
})
|
|
514
537
|
|
|
515
538
|
/** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
|
|
516
539
|
function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean }) {
|
|
@@ -10,12 +10,14 @@ import { type SessionView } from './App.tsx'
|
|
|
10
10
|
vi.mock('../api.ts', () => ({
|
|
11
11
|
listSessions: vi.fn(),
|
|
12
12
|
listWorkspaces: vi.fn(),
|
|
13
|
+
listAgentPresets: vi.fn(),
|
|
13
14
|
createSession: vi.fn(),
|
|
14
15
|
}))
|
|
15
|
-
import { createSession, listSessions, listWorkspaces } from '../api.ts'
|
|
16
|
+
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
16
17
|
|
|
17
18
|
const listSessionsMock = vi.mocked(listSessions)
|
|
18
19
|
const listWorkspacesMock = vi.mocked(listWorkspaces)
|
|
20
|
+
const listAgentPresetsMock = vi.mocked(listAgentPresets)
|
|
19
21
|
const createSessionMock = vi.mocked(createSession)
|
|
20
22
|
|
|
21
23
|
const workspace: WorkspaceRow = {
|
|
@@ -49,6 +51,7 @@ function renderList(props: Partial<SessionListViewProps> = {}): void {
|
|
|
49
51
|
beforeEach(() => {
|
|
50
52
|
listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
|
|
51
53
|
listWorkspacesMock.mockResolvedValue([workspace])
|
|
54
|
+
listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
|
|
52
55
|
createSessionMock.mockResolvedValue({ sessionId: 's-new' })
|
|
53
56
|
})
|
|
54
57
|
|
|
@@ -111,6 +114,71 @@ describe('SessionListView creation', () => {
|
|
|
111
114
|
expect(await screen.findByText('新会话')).toBeTruthy()
|
|
112
115
|
})
|
|
113
116
|
|
|
117
|
+
it('creates the session with the selected agent preset', async () => {
|
|
118
|
+
listAgentPresetsMock.mockResolvedValue({
|
|
119
|
+
presets: [
|
|
120
|
+
{ id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
|
|
121
|
+
{ id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
|
|
122
|
+
],
|
|
123
|
+
authorable: false,
|
|
124
|
+
hasDocument: false,
|
|
125
|
+
})
|
|
126
|
+
renderList()
|
|
127
|
+
|
|
128
|
+
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' })
|
|
129
|
+
expect((picker as HTMLSelectElement).value).toBe('router-standard')
|
|
130
|
+
fireEvent.change(picker, { target: { value: 'router-spec' } })
|
|
131
|
+
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' })
|
|
135
|
+
})
|
|
136
|
+
expect(await screen.findByText('规格驱动模式')).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('skips a broken default preset and disables it in the picker', async () => {
|
|
140
|
+
listAgentPresetsMock.mockResolvedValue({
|
|
141
|
+
presets: [
|
|
142
|
+
{ id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
|
|
143
|
+
{ id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
|
|
144
|
+
],
|
|
145
|
+
authorable: false,
|
|
146
|
+
hasDocument: false,
|
|
147
|
+
})
|
|
148
|
+
renderList()
|
|
149
|
+
|
|
150
|
+
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' }) as HTMLSelectElement
|
|
151
|
+
expect(picker.value).toBe('local-router')
|
|
152
|
+
expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true)
|
|
153
|
+
expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps default session creation available when the preset roster fails', async () => {
|
|
157
|
+
listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'))
|
|
158
|
+
renderList()
|
|
159
|
+
await screen.findByText(/还没有会话/)
|
|
160
|
+
|
|
161
|
+
const button = await screen.findByRole('button', { name: '+ 新建会话' })
|
|
162
|
+
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
163
|
+
fireEvent.click(button)
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('waits for the preset roster before enabling session creation', async () => {
|
|
171
|
+
let resolvePresets: (value: Awaited<ReturnType<typeof listAgentPresets>>) => void = () => {}
|
|
172
|
+
listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve }))
|
|
173
|
+
renderList()
|
|
174
|
+
|
|
175
|
+
const button = screen.getByRole('button', { name: '+ 新建会话' })
|
|
176
|
+
expect(button.hasAttribute('disabled')).toBe(true)
|
|
177
|
+
|
|
178
|
+
resolvePresets({ presets: [], authorable: false, hasDocument: false })
|
|
179
|
+
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
180
|
+
})
|
|
181
|
+
|
|
114
182
|
it('keeps the button disabled while a creation is in flight', async () => {
|
|
115
183
|
let resolveCreate: (value: { sessionId: string }) => void = () => {}
|
|
116
184
|
createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
15
|
import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
16
|
+
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
|
|
16
17
|
import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
|
-
import { createSession, listSessions, listWorkspaces } from '../api.ts'
|
|
18
|
+
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
18
19
|
import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
|
|
19
20
|
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
20
21
|
|
|
@@ -45,6 +46,9 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
45
46
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
46
47
|
const [creating, setCreating] = useState(false)
|
|
47
48
|
const [createError, setCreateError] = useState<string | undefined>(undefined)
|
|
49
|
+
const [presets, setPresets] = useState<readonly AgentPresetEntry[]>([])
|
|
50
|
+
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined)
|
|
51
|
+
const [presetsLoading, setPresetsLoading] = useState(true)
|
|
48
52
|
const cursorRef = useRef<string | undefined>(undefined)
|
|
49
53
|
const busyRef = useRef(false)
|
|
50
54
|
|
|
@@ -74,6 +78,27 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
74
78
|
return () => { cancelled = true }
|
|
75
79
|
}, [workspace])
|
|
76
80
|
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let cancelled = false
|
|
83
|
+
setPresets([])
|
|
84
|
+
setSelectedPreset(undefined)
|
|
85
|
+
setPresetsLoading(true)
|
|
86
|
+
void listAgentPresets().then(
|
|
87
|
+
(roster) => {
|
|
88
|
+
if (cancelled) return
|
|
89
|
+
const usable = roster.presets.filter(preset => preset.broken === undefined)
|
|
90
|
+
setPresets(roster.presets)
|
|
91
|
+
setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
|
|
92
|
+
setPresetsLoading(false)
|
|
93
|
+
},
|
|
94
|
+
() => {
|
|
95
|
+
if (cancelled) return
|
|
96
|
+
setPresetsLoading(false)
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
return () => { cancelled = true }
|
|
100
|
+
}, [workspace])
|
|
101
|
+
|
|
77
102
|
/** Pull the next page and append the rows that belong to this workspace. */
|
|
78
103
|
const loadMore = useCallback(() => {
|
|
79
104
|
if (busyRef.current) return
|
|
@@ -102,7 +127,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
102
127
|
if (creating) return
|
|
103
128
|
setCreating(true)
|
|
104
129
|
setCreateError(undefined)
|
|
105
|
-
void createSession({
|
|
130
|
+
void createSession({
|
|
131
|
+
workspaceId: workspace.workspaceId,
|
|
132
|
+
...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
|
|
133
|
+
}).then(
|
|
106
134
|
(created) => {
|
|
107
135
|
setCreating(false)
|
|
108
136
|
const view: SessionView = {
|
|
@@ -120,9 +148,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
120
148
|
setCreateError(errorText(reason))
|
|
121
149
|
},
|
|
122
150
|
)
|
|
123
|
-
}, [creating, workspace, onPick])
|
|
151
|
+
}, [creating, workspace, onPick, selectedPreset])
|
|
124
152
|
|
|
125
153
|
const createHint = createError !== undefined ? staleHostHint(createError) : undefined
|
|
154
|
+
const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
|
|
126
155
|
|
|
127
156
|
return (
|
|
128
157
|
<div className="mobile">
|
|
@@ -132,11 +161,35 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
132
161
|
<ThemeToggle />
|
|
133
162
|
</header>
|
|
134
163
|
{error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
|
|
135
|
-
<div className="mobile-pad">
|
|
164
|
+
<div className="mobile-create mobile-pad">
|
|
165
|
+
{presets.length > 0 && (
|
|
166
|
+
<label className="mobile-preset">
|
|
167
|
+
<span className="mobile-presetLabel">Agent 模式</span>
|
|
168
|
+
<select
|
|
169
|
+
className="mobile-presetSelect"
|
|
170
|
+
value={selectedPreset ?? ''}
|
|
171
|
+
disabled={selectedPreset === undefined}
|
|
172
|
+
onChange={(event) => { setSelectedPreset(event.target.value) }}
|
|
173
|
+
>
|
|
174
|
+
{selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
|
|
175
|
+
{presets.map(preset => (
|
|
176
|
+
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
177
|
+
{preset.name ?? preset.id}
|
|
178
|
+
{preset.isDefault ? '(默认)' : ''}
|
|
179
|
+
{preset.trust === 'user' ? '(本地)' : ''}
|
|
180
|
+
{preset.broken !== undefined ? '(不可用)' : ''}
|
|
181
|
+
</option>
|
|
182
|
+
))}
|
|
183
|
+
</select>
|
|
184
|
+
</label>
|
|
185
|
+
)}
|
|
186
|
+
{selectedPresetEntry?.description !== undefined && (
|
|
187
|
+
<p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
|
|
188
|
+
)}
|
|
136
189
|
<button
|
|
137
190
|
type="button"
|
|
138
191
|
className="mobile-new"
|
|
139
|
-
disabled={creating}
|
|
192
|
+
disabled={creating || presetsLoading}
|
|
140
193
|
onClick={() => { void handleCreate() }}
|
|
141
194
|
>
|
|
142
195
|
{creating ? '创建中…' : '+ 新建会话'}
|
|
@@ -27,6 +27,8 @@ export interface WorkspaceViewProps {
|
|
|
27
27
|
export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
|
|
28
28
|
const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
|
|
29
29
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
30
|
+
// Bumped by the retry button to re-run the roster fetch effect.
|
|
31
|
+
const [reload, setReload] = useState(0)
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
let cancelled = false
|
|
@@ -48,7 +50,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
|
|
|
48
50
|
},
|
|
49
51
|
)
|
|
50
52
|
return () => { cancelled = true }
|
|
51
|
-
}, [initialWorkspaceId, onPick])
|
|
53
|
+
}, [initialWorkspaceId, onPick, reload])
|
|
52
54
|
|
|
53
55
|
if (error !== undefined) {
|
|
54
56
|
return (
|
|
@@ -59,7 +61,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
|
|
|
59
61
|
</header>
|
|
60
62
|
<div className="mobile-empty">
|
|
61
63
|
<p className="mobile-error">加载失败:{error}</p>
|
|
62
|
-
<button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined) }}>
|
|
64
|
+
<button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined); setReload(n => n + 1) }}>
|
|
63
65
|
重试
|
|
64
66
|
</button>
|
|
65
67
|
</div>
|
package/src/mobile-api.ts
CHANGED
|
@@ -28,9 +28,16 @@ import type { PairingService } from './pairing.ts'
|
|
|
28
28
|
import { readBoundedJson, writeJson } from './http.ts'
|
|
29
29
|
import { readCookie } from './gate.ts'
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Methods the phone surface may call. Everything else is refused HERE — but
|
|
33
|
+
* note the paired-device cookie also passes the global api/gate for the full
|
|
34
|
+
* ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
|
|
35
|
+
* the allowlist only constrains this /m/api proxy, not the cookie's reach.
|
|
36
|
+
* stop() revokes every device; there is no per-device revocation yet.
|
|
37
|
+
*/
|
|
32
38
|
const MOBILE_ALLOWLIST = new Set([
|
|
33
39
|
'workspace.list',
|
|
40
|
+
'agentPreset.list',
|
|
34
41
|
'session.create',
|
|
35
42
|
'session.list',
|
|
36
43
|
'session.history',
|
|
@@ -292,6 +299,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
292
299
|
result: response.result,
|
|
293
300
|
})
|
|
294
301
|
if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
|
|
302
|
+
if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
|
|
295
303
|
if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
|
|
296
304
|
if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
|
|
297
305
|
if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
|
package/src/routes.ts
CHANGED
|
@@ -222,7 +222,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
222
222
|
const ACCEPT_MAX_ATTEMPTS = 10
|
|
223
223
|
const ACCEPT_WINDOW_MS = 30_000
|
|
224
224
|
const rateLimitAccept = (req: IncomingMessage): boolean => {
|
|
225
|
-
const
|
|
225
|
+
const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
226
|
+
// Behind the auto-tunnel every internet client arrives from 127.0.0.1,
|
|
227
|
+
// so a single shared bucket would let one attacker keep the legitimate
|
|
228
|
+
// owner rate-limited. Partition the availability bucket by the first
|
|
229
|
+
// client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
|
|
230
|
+
// authentication and only separates buckets, it never grants access.
|
|
231
|
+
const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
|
|
232
|
+
? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
|
|
233
|
+
: undefined
|
|
234
|
+
const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded
|
|
226
235
|
const nowMs = Date.now()
|
|
227
236
|
// The map lives as long as the plugin: prune expired windows once the
|
|
228
237
|
// table grows past a modest size so distinct source IPs (LAN clients,
|
|
@@ -306,6 +315,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
306
315
|
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
|
|
307
316
|
return
|
|
308
317
|
}
|
|
318
|
+
// No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
|
|
319
|
+
// work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
|
|
320
|
+
// top-level navigations working while blocking cross-site subrequests.
|
|
309
321
|
res.writeHead(200, {
|
|
310
322
|
'content-type': 'application/json; charset=utf-8',
|
|
311
323
|
'set-cookie': [
|
|
@@ -356,7 +368,15 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
356
368
|
return
|
|
357
369
|
}
|
|
358
370
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
359
|
-
|
|
371
|
+
const paired = deviceId !== undefined && service.hasDevice(deviceId)
|
|
372
|
+
const snapshot = service.snapshot()
|
|
373
|
+
// Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
|
|
374
|
+
// token expiry, device roster, and public tunnel URL are an oracle for
|
|
375
|
+
// targeting and timing and stay behind a live device cookie.
|
|
376
|
+
const visible = paired
|
|
377
|
+
? snapshot
|
|
378
|
+
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
|
|
379
|
+
writeJson(res, 200, { ok: true, paired, ...visible })
|
|
360
380
|
}
|
|
361
381
|
|
|
362
382
|
const handleEvents = (req: IncomingMessage, res: ServerResponse): void => {
|