@linxin666/dsh-remote-web-ui 0.3.1 → 0.3.3
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 +22 -8
- package/README.zh.md +12 -7
- package/lib/client.js +174 -16
- package/lib/client.js.map +1 -1
- package/lib/index.js +104 -7
- package/lib/mobile.js +62 -7
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/RemoteEntry.js +1 -1
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +20 -3
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +4 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +22 -10
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +14 -3
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/mobile/api.d.ts +7 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +7 -0
- 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 +10 -0
- package/lib/types/mobile/views/ChatView.d.ts +1 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +32 -5
- package/lib/types/mobile/views/ChatView.stream.test.js +28 -2
- package/lib/types/mobile/views/ChatView.test.js +152 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +3 -0
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/update.d.ts +35 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +98 -4
- package/package.json +2 -2
- package/src/client/RemoteEntry.tsx +1 -1
- package/src/client/UpdatePanel.tsx +43 -3
- package/src/client/index.ts +6 -1
- package/src/client/locales.ts +22 -10
- package/src/client/remote.module.css +56 -3
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/http.ts +1 -1
- package/src/index.ts +16 -2
- package/src/loopback.ts +1 -1
- package/src/mobile/api.ts +8 -0
- package/src/mobile/mobile-styles.ts +10 -0
- package/src/mobile/views/ChatView.stream.test.tsx +34 -2
- package/src/mobile/views/ChatView.test.tsx +177 -2
- package/src/mobile/views/ChatView.tsx +44 -6
- package/src/mobile-api.ts +2 -0
- package/src/mount-once.ts +2 -2
- package/src/update.ts +117 -4
package/src/index.ts
CHANGED
|
@@ -31,10 +31,13 @@ import { lanIPv4Addresses } from './lan.ts'
|
|
|
31
31
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
32
32
|
import {
|
|
33
33
|
checkUpdates,
|
|
34
|
+
fetchGitHubReleaseNotes,
|
|
34
35
|
fetchLatestVersion,
|
|
36
|
+
RELEASE_NOTES_CACHE_TTL_MS,
|
|
35
37
|
resolveAnchorManifest,
|
|
36
38
|
resolveUpdateTarget,
|
|
37
39
|
runUpdateVerified,
|
|
40
|
+
type UpdateReleaseNotes,
|
|
38
41
|
type UpdateRunResult,
|
|
39
42
|
} from './update.ts'
|
|
40
43
|
import { makeUpdateRoutes } from './update-routes.ts'
|
|
@@ -294,7 +297,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
294
297
|
console.warn('remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled')
|
|
295
298
|
}
|
|
296
299
|
// ── remote update ────────────────────────────────────────────────────────
|
|
297
|
-
// The dsh-web
|
|
300
|
+
// The dsh-web self-update surface: probe the npm registry for family
|
|
298
301
|
// releases and run `pnpm update --latest` in the owning profile. Resolutions
|
|
299
302
|
// anchor on the host process's own module graph, so the update always
|
|
300
303
|
// targets the profile the running web GUI was booted from. The anchor path
|
|
@@ -309,6 +312,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
309
312
|
return undefined
|
|
310
313
|
}
|
|
311
314
|
})
|
|
315
|
+
|
|
316
|
+
const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
|
|
317
|
+
const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
|
|
318
|
+
const cached = releaseNotesCache.get(version)
|
|
319
|
+
if (cached !== undefined && Date.now() - cached.at < RELEASE_NOTES_CACHE_TTL_MS) return cached.notes
|
|
320
|
+
const notes = await fetchGitHubReleaseNotes(version, fetch)
|
|
321
|
+
releaseNotesCache.set(version, { at: Date.now(), notes })
|
|
322
|
+
return notes
|
|
323
|
+
}
|
|
312
324
|
const updateRoutes = makeUpdateRoutes({
|
|
313
325
|
// Control endpoints are host-surface only: a LAN/phone origin must never
|
|
314
326
|
// trigger a real install on this machine.
|
|
@@ -323,6 +335,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
323
335
|
}
|
|
324
336
|
},
|
|
325
337
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
338
|
+
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
326
339
|
}),
|
|
327
340
|
run: async (): Promise<UpdateRunResult> => {
|
|
328
341
|
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
|
|
@@ -332,7 +345,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
332
345
|
ok: false,
|
|
333
346
|
exitCode: null,
|
|
334
347
|
output: '',
|
|
335
|
-
error: code === 'not-found' ? 'dsh-web
|
|
348
|
+
error: code === 'not-found' ? 'dsh-web aggregate not installed' : 'local link install — update unavailable',
|
|
336
349
|
errorCode: code,
|
|
337
350
|
}
|
|
338
351
|
}
|
|
@@ -352,6 +365,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
352
365
|
}
|
|
353
366
|
},
|
|
354
367
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
368
|
+
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
355
369
|
},
|
|
356
370
|
})
|
|
357
371
|
},
|
package/src/loopback.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
9
|
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
10
|
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
-
* @module dsh-web-
|
|
11
|
+
* @module dsh-web-shared/host/loopback
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { IncomingMessage } from 'node:http'
|
package/src/mobile/api.ts
CHANGED
|
@@ -112,6 +112,14 @@ export async function sendCommand(sessionId: string, line: string): Promise<unkn
|
|
|
112
112
|
})
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Stop the session's active turn (the mobile stop button). Pending queued
|
|
117
|
+
* work is preserved and resumes in FIFO order once cancellation settles.
|
|
118
|
+
*/
|
|
119
|
+
export async function cancelSession(sessionId: string): Promise<{ accepted: true }> {
|
|
120
|
+
return await callUnary<{ accepted: true }>('session.cancel', { sessionId })
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
/** Fresh advisory model directory for one session (current + groups + failures). */
|
|
116
124
|
export async function models(sessionId: string): Promise<SessionModels> {
|
|
117
125
|
return await callUnary<SessionModels>('session.models', { sessionId })
|
|
@@ -1170,6 +1170,16 @@ body {
|
|
|
1170
1170
|
opacity: 0.5;
|
|
1171
1171
|
}
|
|
1172
1172
|
|
|
1173
|
+
/* Running state: the primary button carries only the square stop icon, so
|
|
1174
|
+
it centers the glyph instead of sizing to text. */
|
|
1175
|
+
.chat-send-stop {
|
|
1176
|
+
display: inline-flex;
|
|
1177
|
+
align-items: center;
|
|
1178
|
+
justify-content: center;
|
|
1179
|
+
min-width: 44px;
|
|
1180
|
+
padding: 0 12px;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1173
1183
|
.chat-load-older {
|
|
1174
1184
|
align-self: center;
|
|
1175
1185
|
margin: 4px 0;
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* to the plain renderMarkdown output) the moment the turn closes.
|
|
8
8
|
*/
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
-
import { act, cleanup, render } from '@testing-library/react'
|
|
11
|
-
import { ChatView, STREAM_RENDER_INTERVAL_MS } from './ChatView.tsx'
|
|
10
|
+
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
|
|
12
12
|
import { type SessionView } from './App.tsx'
|
|
13
13
|
import { renderMarkdown } from '../markdown.ts'
|
|
14
14
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
@@ -22,6 +22,8 @@ vi.mock('../api.ts', () => ({
|
|
|
22
22
|
models: vi.fn(),
|
|
23
23
|
selectModel: vi.fn(),
|
|
24
24
|
sendCommand: vi.fn(),
|
|
25
|
+
cancelSession: vi.fn(),
|
|
26
|
+
fetchPending: vi.fn(),
|
|
25
27
|
}))
|
|
26
28
|
vi.mock('./App.tsx', async importOriginal => {
|
|
27
29
|
const actual = await importOriginal<typeof import('./App.tsx')>()
|
|
@@ -228,4 +230,34 @@ describe('ChatView streaming markdown throttle', () => {
|
|
|
228
230
|
expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0)
|
|
229
231
|
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
|
|
230
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
|
+
})
|
|
231
263
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
6
|
-
import { ChatView, MAX_TAIL_BUFFER_EVENTS } from './ChatView.tsx'
|
|
6
|
+
import { ChatView, MAX_TAIL_BUFFER_EVENTS, LONG_TEXT_LIMIT } from './ChatView.tsx'
|
|
7
7
|
import { type SessionView } from './App.tsx'
|
|
8
8
|
import type { HistoryPage } from '../api.ts'
|
|
9
9
|
import type { WireEvent } from '../messages.ts'
|
|
@@ -15,6 +15,8 @@ vi.mock('../api.ts', () => ({
|
|
|
15
15
|
models: vi.fn(),
|
|
16
16
|
selectModel: vi.fn(),
|
|
17
17
|
sendCommand: vi.fn(),
|
|
18
|
+
cancelSession: vi.fn(),
|
|
19
|
+
fetchPending: vi.fn(),
|
|
18
20
|
}))
|
|
19
21
|
vi.mock('./App.tsx', async importOriginal => {
|
|
20
22
|
const actual = await importOriginal<typeof import('./App.tsx')>()
|
|
@@ -24,7 +26,7 @@ vi.mock('./App.tsx', async importOriginal => {
|
|
|
24
26
|
prompt: vi.fn(async () => {}),
|
|
25
27
|
}
|
|
26
28
|
})
|
|
27
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
29
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending } from '../api.ts'
|
|
28
30
|
import { loadHistory, prompt } from './App.tsx'
|
|
29
31
|
|
|
30
32
|
const session: SessionView = {
|
|
@@ -45,6 +47,18 @@ function historyPage(events: Array<{ event: WireEvent }>, extra: Record<string,
|
|
|
45
47
|
return { events: events as never, hasMore: false, ...extra } as HistoryPage
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
51
|
+
class FakeMux {
|
|
52
|
+
listeners = new Set<(frame: unknown) => void>()
|
|
53
|
+
onFrame(listener: (frame: unknown) => void): () => void {
|
|
54
|
+
this.listeners.add(listener)
|
|
55
|
+
return () => { this.listeners.delete(listener) }
|
|
56
|
+
}
|
|
57
|
+
emit(frame: unknown): void {
|
|
58
|
+
for (const listener of this.listeners) listener(frame)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
/** A full turn: user message, reasoning + text chunks, tool calls, final message. */
|
|
49
63
|
function turnEvents(): Array<{ event: WireEvent }> {
|
|
50
64
|
return [
|
|
@@ -72,6 +86,8 @@ const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
|
|
|
72
86
|
const modelsMock = vi.mocked(models)
|
|
73
87
|
const selectModelMock = vi.mocked(selectModel)
|
|
74
88
|
const sendCommandMock = vi.mocked(sendCommand)
|
|
89
|
+
const cancelSessionMock = vi.mocked(cancelSession)
|
|
90
|
+
const fetchPendingMock = vi.mocked(fetchPending)
|
|
75
91
|
const loadHistoryMock = vi.mocked(loadHistory)
|
|
76
92
|
const promptMock = vi.mocked(prompt)
|
|
77
93
|
|
|
@@ -95,6 +111,8 @@ beforeEach(() => {
|
|
|
95
111
|
} satisfies SessionModels)
|
|
96
112
|
selectModelMock.mockResolvedValue({ selected: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' } })
|
|
97
113
|
sendCommandMock.mockResolvedValue({})
|
|
114
|
+
cancelSessionMock.mockResolvedValue({ accepted: true })
|
|
115
|
+
fetchPendingMock.mockResolvedValue({ approvals: [], questions: [] })
|
|
98
116
|
})
|
|
99
117
|
|
|
100
118
|
afterEach(() => {
|
|
@@ -639,3 +657,160 @@ describe('ChatView display toggles and context usage', () => {
|
|
|
639
657
|
expect(screen.queryByText(/上下文/)).toBeNull()
|
|
640
658
|
})
|
|
641
659
|
})
|
|
660
|
+
|
|
661
|
+
describe('ChatView stop button (#1041)', () => {
|
|
662
|
+
/** Emit one session lifecycle frame for the chat's session. */
|
|
663
|
+
function emitSessionEvent(mux: FakeMux, type: string, seq: number): void {
|
|
664
|
+
act(() => {
|
|
665
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry(type, {}, seq).event })
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
it('switches the composer primary to a stop button while running and cancels the turn', async () => {
|
|
670
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
671
|
+
const mux = new FakeMux()
|
|
672
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
673
|
+
|
|
674
|
+
// Idle: the primary button is the (empty-draft disabled) send button.
|
|
675
|
+
const sendButton = await screen.findByRole('button', { name: '发送' })
|
|
676
|
+
expect((sendButton as HTMLButtonElement).disabled).toBe(true)
|
|
677
|
+
|
|
678
|
+
// Turn starts: the primary becomes an enabled stop button (square icon).
|
|
679
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
680
|
+
const stopButton = (await screen.findByRole('button', { name: '停止' })) as HTMLButtonElement
|
|
681
|
+
expect(stopButton.disabled).toBe(false)
|
|
682
|
+
|
|
683
|
+
fireEvent.click(stopButton)
|
|
684
|
+
await waitFor(() => expect(cancelSessionMock).toHaveBeenCalledWith('s-1'))
|
|
685
|
+
|
|
686
|
+
// Turn ends: the primary flips back to send.
|
|
687
|
+
emitSessionEvent(mux, 'turn/end', 2)
|
|
688
|
+
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy()
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('disables the stop button while the cancel request is in flight', async () => {
|
|
692
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
693
|
+
let resolveCancel: (() => void) | undefined
|
|
694
|
+
cancelSessionMock.mockReturnValue(new Promise<{ accepted: true }>((resolve) => {
|
|
695
|
+
resolveCancel = () => { resolve({ accepted: true }) }
|
|
696
|
+
}))
|
|
697
|
+
const mux = new FakeMux()
|
|
698
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
699
|
+
await screen.findByRole('button', { name: '发送' })
|
|
700
|
+
|
|
701
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
702
|
+
const stopButton = (await screen.findByRole('button', { name: '停止' })) as HTMLButtonElement
|
|
703
|
+
fireEvent.click(stopButton)
|
|
704
|
+
|
|
705
|
+
// In flight: disabled and re-labeled; a second tap cannot double-submit.
|
|
706
|
+
const inflight = (await screen.findByRole('button', { name: '停止中' })) as HTMLButtonElement
|
|
707
|
+
expect(inflight.disabled).toBe(true)
|
|
708
|
+
fireEvent.click(inflight)
|
|
709
|
+
expect(cancelSessionMock).toHaveBeenCalledTimes(1)
|
|
710
|
+
|
|
711
|
+
await act(async () => { resolveCancel?.() })
|
|
712
|
+
emitSessionEvent(mux, 'turn/end', 2)
|
|
713
|
+
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy()
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it('surfaces a cancel failure through the chat error line', async () => {
|
|
717
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
718
|
+
cancelSessionMock.mockRejectedValue(new Error('cancel exploded'))
|
|
719
|
+
const mux = new FakeMux()
|
|
720
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
721
|
+
await screen.findByRole('button', { name: '发送' })
|
|
722
|
+
|
|
723
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
724
|
+
fireEvent.click(await screen.findByRole('button', { name: '停止' }))
|
|
725
|
+
expect(await screen.findByText(/cancel exploded/)).toBeTruthy()
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
describe('ChatView message visibility and long text folding (#1065)', () => {
|
|
730
|
+
const toolOnlyEvents = (): Array<{ event: WireEvent }> => [
|
|
731
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '查询文件' }] }, 0),
|
|
732
|
+
makeEntry('assistant/message', {
|
|
733
|
+
turn: 0,
|
|
734
|
+
step: 0,
|
|
735
|
+
message: { id: 'a-1', role: 'assistant', content: [] },
|
|
736
|
+
}, 1),
|
|
737
|
+
makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'read_file', arguments: '{"path":"/a"}' }, 2),
|
|
738
|
+
]
|
|
739
|
+
|
|
740
|
+
it('hides assistant message completely (no air bubble) when only tool calls exist and tool toggle is off', async () => {
|
|
741
|
+
loadHistoryMock.mockResolvedValue(historyPage(toolOnlyEvents()))
|
|
742
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
743
|
+
|
|
744
|
+
expect(await screen.findByText('查询文件')).toBeTruthy()
|
|
745
|
+
// By default showToolCalls is true, tool disclosure is visible
|
|
746
|
+
expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy()
|
|
747
|
+
|
|
748
|
+
// Turn off tool-calls toggle
|
|
749
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }))
|
|
750
|
+
const toolSwitch = await screen.findByRole('switch', { name: '工具调用' })
|
|
751
|
+
fireEvent.click(toolSwitch)
|
|
752
|
+
|
|
753
|
+
// The tool disclosure is gone, and the entire assistant message bubble is not rendered (no air bubble)
|
|
754
|
+
expect(screen.queryByRole('button', { name: /工具/ })).toBeNull()
|
|
755
|
+
const msgElements = container.querySelectorAll('.chat-msg')
|
|
756
|
+
expect(msgElements.length).toBe(1)
|
|
757
|
+
expect(msgElements[0]?.classList.contains('chat-msg-user')).toBe(true)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('renders failed tag even if assistant message has no text or reasoning', async () => {
|
|
761
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
762
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '测试失败' }] }, 0),
|
|
763
|
+
makeEntry('assistant/message', {
|
|
764
|
+
turn: 0,
|
|
765
|
+
step: 0,
|
|
766
|
+
message: { id: 'a-1', role: 'assistant', content: [] },
|
|
767
|
+
}, 1),
|
|
768
|
+
makeEntry('turn/end', { turn: 0, reason: { kind: 'error', message: 'timeout' } }, 2),
|
|
769
|
+
]))
|
|
770
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
771
|
+
|
|
772
|
+
expect(await screen.findByText('测试失败')).toBeTruthy()
|
|
773
|
+
expect(await screen.findByText('本次回复失败')).toBeTruthy()
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('collapses terminal assistant text exceeding LONG_TEXT_LIMIT and toggles open/close', async () => {
|
|
777
|
+
const longText = 'A'.repeat(LONG_TEXT_LIMIT + 100)
|
|
778
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
779
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成长文本' }] }, 0),
|
|
780
|
+
makeEntry('assistant/message', {
|
|
781
|
+
turn: 0,
|
|
782
|
+
step: 0,
|
|
783
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: longText }] },
|
|
784
|
+
}, 1),
|
|
785
|
+
]))
|
|
786
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
787
|
+
|
|
788
|
+
expect(await screen.findByText('生成长文本')).toBeTruthy()
|
|
789
|
+
const toggleButton = await screen.findByRole('button', { name: new RegExp(`展开全文(${LONG_TEXT_LIMIT + 100} 字)`) })
|
|
790
|
+
expect(toggleButton).toBeTruthy()
|
|
791
|
+
expect(container.querySelector('.chat-md-collapsed')).not.toBeNull()
|
|
792
|
+
|
|
793
|
+
// Expand
|
|
794
|
+
fireEvent.click(toggleButton)
|
|
795
|
+
expect(await screen.findByRole('button', { name: '收起' })).toBeTruthy()
|
|
796
|
+
expect(container.querySelector('.chat-md-collapsed')).toBeNull()
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('does not collapse terminal assistant text within LONG_TEXT_LIMIT', async () => {
|
|
800
|
+
const shortText = 'B'.repeat(LONG_TEXT_LIMIT)
|
|
801
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
802
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成中等文本' }] }, 0),
|
|
803
|
+
makeEntry('assistant/message', {
|
|
804
|
+
turn: 0,
|
|
805
|
+
step: 0,
|
|
806
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: shortText }] },
|
|
807
|
+
}, 1),
|
|
808
|
+
]))
|
|
809
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
810
|
+
|
|
811
|
+
expect(await screen.findByText('生成中等文本')).toBeTruthy()
|
|
812
|
+
expect(screen.queryByRole('button', { name: /展开全文/ })).toBeNull()
|
|
813
|
+
expect(container.querySelector('.chat-md-collapsed')).toBeNull()
|
|
814
|
+
})
|
|
815
|
+
})
|
|
816
|
+
|
|
@@ -16,7 +16,7 @@ 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
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand, fetchPending, respondApproval, respondQuestion } from '../api.ts'
|
|
19
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending, respondApproval, respondQuestion } from '../api.ts'
|
|
20
20
|
import type { PendingApproval, PendingQuestionItem } from '../api.ts'
|
|
21
21
|
import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
22
22
|
import { renderMarkdown } from '../markdown.ts'
|
|
@@ -167,6 +167,8 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
167
167
|
const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
|
|
168
168
|
/** Whether the assistant is currently generating (turn/start..turn/end). */
|
|
169
169
|
const [running, setRunning] = useState(false)
|
|
170
|
+
/** Whether a stop request is in flight (guards the composer's stop button). */
|
|
171
|
+
const [stopping, setStopping] = useState(false)
|
|
170
172
|
/** Pending tool approvals awaiting user decision (#1025). */
|
|
171
173
|
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([])
|
|
172
174
|
/** Pending questions awaiting user answer (#1025). */
|
|
@@ -441,6 +443,24 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
441
443
|
)
|
|
442
444
|
}, [input, sending, session.sessionId])
|
|
443
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Stop the active turn (desktop parity: the composer's primary button
|
|
448
|
+
* becomes a stop button while running). The turn/end frame arriving over
|
|
449
|
+
* mux flips the button back; a failed request surfaces through the chat
|
|
450
|
+
* error line.
|
|
451
|
+
*/
|
|
452
|
+
const stopTurn = useCallback(() => {
|
|
453
|
+
if (stopping) return
|
|
454
|
+
setStopping(true)
|
|
455
|
+
void cancelSession(session.sessionId).then(
|
|
456
|
+
() => { setStopping(false) },
|
|
457
|
+
(reason: unknown) => {
|
|
458
|
+
setStopping(false)
|
|
459
|
+
setError(errorText(reason))
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
}, [stopping, session.sessionId])
|
|
463
|
+
|
|
444
464
|
const modelLabel = currentModel?.model ?? '模型'
|
|
445
465
|
const permissionLabel = permissions === undefined
|
|
446
466
|
? undefined
|
|
@@ -547,8 +567,18 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
547
567
|
}
|
|
548
568
|
}}
|
|
549
569
|
/>
|
|
550
|
-
<button
|
|
551
|
-
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
className={running ? 'chat-send chat-send-stop' : 'chat-send'}
|
|
573
|
+
{...(running ? { 'aria-label': stopping ? '停止中' : '停止' } : {})}
|
|
574
|
+
disabled={running ? stopping : sending || input.trim() === ''}
|
|
575
|
+
onClick={() => { if (running) void stopTurn(); else void send() }}
|
|
576
|
+
>
|
|
577
|
+
{running ? (
|
|
578
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
|
579
|
+
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
|
580
|
+
</svg>
|
|
581
|
+
) : sending ? '发送中…' : '发送'}
|
|
552
582
|
</button>
|
|
553
583
|
</div>
|
|
554
584
|
{sheet === 'model' && (
|
|
@@ -595,13 +625,21 @@ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystem
|
|
|
595
625
|
showSystemMessages: boolean
|
|
596
626
|
}) {
|
|
597
627
|
// Injected user messages (sourceKind defined and not 'user') hide behind
|
|
598
|
-
// the system-message toggle.
|
|
628
|
+
// the system-message toggle.
|
|
599
629
|
if (message.kind === 'user'
|
|
600
630
|
&& message.sourceKind !== undefined
|
|
601
631
|
&& message.sourceKind !== 'user'
|
|
602
632
|
&& !showSystemMessages) {
|
|
603
633
|
return null
|
|
604
634
|
}
|
|
635
|
+
const hasReasoning = message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== ''
|
|
636
|
+
const hasTools = showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0
|
|
637
|
+
const hasText = message.text !== ''
|
|
638
|
+
const hasFailTag = message.failed === true
|
|
639
|
+
|
|
640
|
+
if (!hasReasoning && !hasTools && !hasText && !hasFailTag) {
|
|
641
|
+
return null
|
|
642
|
+
}
|
|
605
643
|
return (
|
|
606
644
|
<div className={`chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`}>
|
|
607
645
|
{message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (
|
|
@@ -750,7 +788,7 @@ function MarkdownText({ text, pending }: { text: string; pending: boolean }) {
|
|
|
750
788
|
if (timerRef.current !== undefined) clearTimeout(timerRef.current)
|
|
751
789
|
}
|
|
752
790
|
}, [])
|
|
753
|
-
const long = text.length > LONG_TEXT_LIMIT
|
|
791
|
+
const long = !pending && text.length > LONG_TEXT_LIMIT
|
|
754
792
|
const collapsed = long && !open
|
|
755
793
|
return (
|
|
756
794
|
<div className={'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : '')}>
|
|
@@ -781,7 +819,7 @@ function CollapsibleText({ text }: { text: string }) {
|
|
|
781
819
|
)
|
|
782
820
|
}
|
|
783
821
|
|
|
784
|
-
const LONG_TEXT_LIMIT =
|
|
822
|
+
export const LONG_TEXT_LIMIT = 6000
|
|
785
823
|
const LONG_TEXT_PREVIEW = 800
|
|
786
824
|
|
|
787
825
|
/** Latest non-empty line of a streaming reasoning buffer. */
|
package/src/mobile-api.ts
CHANGED
|
@@ -53,6 +53,7 @@ const MOBILE_ALLOWLIST = new Set([
|
|
|
53
53
|
'session.models',
|
|
54
54
|
'session.selectModel',
|
|
55
55
|
'session.rename',
|
|
56
|
+
'session.cancel',
|
|
56
57
|
])
|
|
57
58
|
|
|
58
59
|
/**
|
|
@@ -353,5 +354,6 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
353
354
|
if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
|
|
354
355
|
if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
|
|
355
356
|
if (method === 'session.rename') return wrap(await apiProxy.sessions.rename(request as never))
|
|
357
|
+
if (method === 'session.cancel') return wrap(await apiProxy.sessions.cancel(request as never))
|
|
356
358
|
throw new Error(`unhandled allowlisted method ${method}`)
|
|
357
359
|
}
|
package/src/mount-once.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
2
|
/**
|
|
3
3
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
-
* (dsh-web-
|
|
4
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const MOUNTED = Symbol.for('dsh-web
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
19
|
|
|
20
20
|
interface MountRegistry {
|
|
21
21
|
[MOUNTED]?: Set<string>
|