@linxin666/dsh-remote-web-ui 0.2.8 → 0.3.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/LICENSE +202 -28
- package/README.i18n.yaml +2 -2
- package/README.md +51 -15
- package/README.zh.md +9 -4
- package/lib/client.js +65 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -77
- package/lib/mobile.js +874 -64
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +35 -5
- package/lib/types/client/remote-channel.d.ts +4 -5
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +13 -28
- package/lib/types/http.d.ts +37 -8
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +76 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +40 -5
- package/lib/types/mobile/api.d.ts +57 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +29 -0
- package/lib/types/mobile/markdown.d.ts +7 -1
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +16 -7
- package/lib/types/mobile/markdown.test.js +8 -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 +325 -6
- package/lib/types/mobile/views/ChatView.d.ts +10 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +205 -8
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +46 -5
- package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
- package/lib/types/mobile-api.d.ts +9 -3
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +61 -16
- package/lib/types/mobile-pending.d.ts +38 -0
- package/lib/types/mobile-pending.d.ts.map +1 -0
- package/lib/types/mobile-pending.js +59 -0
- package/lib/types/paired-model-catalog.d.ts +17 -0
- package/lib/types/paired-model-catalog.d.ts.map +1 -0
- package/lib/types/paired-model-catalog.js +252 -0
- package/lib/types/pairing.d.ts +13 -4
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -5
- package/lib/types/remote-api.d.ts +13 -4
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +27 -17
- package/lib/types/remote-channel-boot.d.ts +26 -0
- package/lib/types/remote-channel-boot.d.ts.map +1 -0
- package/lib/types/remote-channel-boot.js +128 -0
- package/lib/types/remote-channel-rules.d.ts +40 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -0
- package/lib/types/remote-channel-rules.js +32 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +12 -23
- package/lib/types/uuid-polyfill.d.ts +10 -0
- package/lib/types/uuid-polyfill.d.ts.map +1 -0
- package/lib/types/uuid-polyfill.js +27 -0
- package/package.json +1 -1
- package/src/client/index.ts +41 -5
- package/src/client/remote-channel.ts +18 -28
- package/src/http.ts +87 -12
- package/src/index.ts +42 -6
- package/src/mobile/api.ts +91 -0
- package/src/mobile/markdown.test.ts +9 -0
- package/src/mobile/markdown.ts +17 -7
- package/src/mobile/mobile-styles.ts +326 -6
- package/src/mobile/views/ChatView.stream.test.tsx +231 -0
- package/src/mobile/views/ChatView.tsx +328 -10
- package/src/mobile/views/WorkspaceView.test.tsx +163 -2
- package/src/mobile/views/WorkspaceView.tsx +152 -26
- package/src/mobile-api.ts +59 -17
- package/src/mobile-pending.ts +88 -0
- package/src/paired-model-catalog.ts +224 -0
- package/src/pairing.ts +13 -5
- package/src/remote-api.ts +34 -18
- package/src/remote-channel-boot.ts +131 -0
- package/src/remote-channel-rules.ts +62 -0
- package/src/routes.ts +13 -24
- package/src/uuid-polyfill.ts +28 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* ChatView streaming markdown throttle: a pending assistant message must not
|
|
4
|
+
* re-parse its whole accumulated text on every chunk. Streaming text
|
|
5
|
+
* re-parses at most once per STREAM_RENDER_INTERVAL_MS, shows the last
|
|
6
|
+
* parsed result in between, and renders the exact final HTML (byte-identical
|
|
7
|
+
* to the plain renderMarkdown output) the moment the turn closes.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
11
|
+
import { ChatView, STREAM_RENDER_INTERVAL_MS } from './ChatView.tsx'
|
|
12
|
+
import { type SessionView } from './App.tsx'
|
|
13
|
+
import { renderMarkdown } from '../markdown.ts'
|
|
14
|
+
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
15
|
+
import type { HistoryPage } from '../api.ts'
|
|
16
|
+
import type { WireEvent } from '../messages.ts'
|
|
17
|
+
|
|
18
|
+
// The api module is fully mocked; App.tsx's history wrapper is overridden to
|
|
19
|
+
// feed a fixed empty history page, its pure helpers stay real.
|
|
20
|
+
vi.mock('../api.ts', () => ({
|
|
21
|
+
fetchMobilePreferences: vi.fn(),
|
|
22
|
+
models: vi.fn(),
|
|
23
|
+
selectModel: vi.fn(),
|
|
24
|
+
sendCommand: vi.fn(),
|
|
25
|
+
}))
|
|
26
|
+
vi.mock('./App.tsx', async importOriginal => {
|
|
27
|
+
const actual = await importOriginal<typeof import('./App.tsx')>()
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
loadHistory: vi.fn(),
|
|
31
|
+
prompt: vi.fn(async () => {}),
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
// Wrap the real renderer with a call counter: the throttle must cut the
|
|
35
|
+
// number of full-text parses during a chunk stream (each parse is O(n)).
|
|
36
|
+
vi.mock('../markdown.ts', async importOriginal => {
|
|
37
|
+
const actual = await importOriginal<typeof import('../markdown.ts')>()
|
|
38
|
+
return { ...actual, renderMarkdown: vi.fn(actual.renderMarkdown) }
|
|
39
|
+
})
|
|
40
|
+
import { fetchMobilePreferences, models } from '../api.ts'
|
|
41
|
+
import { loadHistory } from './App.tsx'
|
|
42
|
+
|
|
43
|
+
const loadHistoryMock = vi.mocked(loadHistory)
|
|
44
|
+
const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
|
|
45
|
+
const modelsMock = vi.mocked(models)
|
|
46
|
+
const renderMarkdownMock = vi.mocked(renderMarkdown)
|
|
47
|
+
|
|
48
|
+
const session: SessionView = {
|
|
49
|
+
sessionId: 's-1',
|
|
50
|
+
title: '测试会话',
|
|
51
|
+
updatedAt: 1_700_000_000_000,
|
|
52
|
+
running: false,
|
|
53
|
+
blank: false,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
57
|
+
class FakeMux {
|
|
58
|
+
listeners = new Set<(frame: unknown) => void>()
|
|
59
|
+
onFrame(listener: (frame: unknown) => void): () => void {
|
|
60
|
+
this.listeners.add(listener)
|
|
61
|
+
return () => { this.listeners.delete(listener) }
|
|
62
|
+
}
|
|
63
|
+
emit(frame: unknown): void {
|
|
64
|
+
for (const listener of this.listeners) listener(frame)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One history entry wrapping a WireEvent (host history-page shape). */
|
|
69
|
+
function makeEntry(type: string, data: unknown, seq: number): { event: WireEvent } {
|
|
70
|
+
return { event: { type, seq, time: seq * 1_000, data } }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build an empty history page (the live stream supplies all content). */
|
|
74
|
+
function historyPage(): HistoryPage {
|
|
75
|
+
return { events: [] as never, hasMore: false } as HistoryPage
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Emit one live text-delta chunk bound to the stable message id 'a-1' (the
|
|
80
|
+
* mobile wire shape). A stable id keeps the message row mounted across the
|
|
81
|
+
* fold, so the tests observe the throttle effect path only.
|
|
82
|
+
*/
|
|
83
|
+
function chunk(mux: FakeMux, text: string, seq: number): void {
|
|
84
|
+
mux.emit({
|
|
85
|
+
type: 'session/event',
|
|
86
|
+
sessionId: 's-1',
|
|
87
|
+
event: makeEntry('assistant/chunk', { messageId: 'a-1', turn: 0, step: 0, text }, seq).event,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Emit the final assistant message (authoritative text, closes the turn). */
|
|
92
|
+
function finalMessage(mux: FakeMux, text: string, seq: number): void {
|
|
93
|
+
mux.emit({
|
|
94
|
+
type: 'session/event',
|
|
95
|
+
sessionId: 's-1',
|
|
96
|
+
event: makeEntry('assistant/message', {
|
|
97
|
+
turn: 0,
|
|
98
|
+
step: 0,
|
|
99
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text }] },
|
|
100
|
+
}, seq).event,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Round-trip HTML through the DOM so a byte comparison is symmetric. */
|
|
105
|
+
function normalize(html: string): string {
|
|
106
|
+
const element = document.createElement('div')
|
|
107
|
+
element.innerHTML = html
|
|
108
|
+
return element.innerHTML
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The assistant message's markdown body (dangerouslySetInnerHTML target). */
|
|
112
|
+
function body(): HTMLElement {
|
|
113
|
+
const element = document.querySelector('.chat-msg.chat-msg-assistant .chat-md-body')
|
|
114
|
+
if (element === null) throw new Error('assistant markdown body not found')
|
|
115
|
+
return element as HTMLElement
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true })
|
|
120
|
+
modelsMock.mockResolvedValue({
|
|
121
|
+
current: { provider: 'fx', model: 'fx-1' },
|
|
122
|
+
routable: true,
|
|
123
|
+
groups: [],
|
|
124
|
+
failures: [],
|
|
125
|
+
} satisfies SessionModels)
|
|
126
|
+
loadHistoryMock.mockResolvedValue(historyPage())
|
|
127
|
+
vi.useFakeTimers()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
cleanup()
|
|
132
|
+
vi.useRealTimers()
|
|
133
|
+
vi.clearAllMocks()
|
|
134
|
+
vi.restoreAllMocks()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('ChatView streaming markdown throttle', () => {
|
|
138
|
+
it('re-parses pending text at most once per interval and catches up byte-exactly', async () => {
|
|
139
|
+
const mux = new FakeMux()
|
|
140
|
+
await act(async () => {
|
|
141
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const parts = [
|
|
145
|
+
'第一段 **加粗** 文本与 `行内代码`。\n\n',
|
|
146
|
+
'第二段 *斜体* 与 <标签> 内容。\n\n',
|
|
147
|
+
'第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
|
|
148
|
+
]
|
|
149
|
+
const full = parts.join('')
|
|
150
|
+
|
|
151
|
+
// The first chunk mounts the pending message; the mount render parses it.
|
|
152
|
+
await act(async () => { chunk(mux, parts[0] ?? '', 6) })
|
|
153
|
+
const afterMount = renderMarkdownMock.mock.calls.length
|
|
154
|
+
expect(afterMount).toBeGreaterThanOrEqual(1)
|
|
155
|
+
|
|
156
|
+
// A burst of further chunks does not re-parse per chunk: at most one
|
|
157
|
+
// leading parse may run (if the wall-clock gap to the burst exceeded the
|
|
158
|
+
// window), but never one parse per chunk.
|
|
159
|
+
await act(async () => {
|
|
160
|
+
chunk(mux, parts[1] ?? '', 7)
|
|
161
|
+
chunk(mux, parts[2] ?? '', 8)
|
|
162
|
+
})
|
|
163
|
+
expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(2)
|
|
164
|
+
// During the window the last parsed result stays visible: the newest
|
|
165
|
+
// chunk's text has not reached the DOM yet.
|
|
166
|
+
expect(body().innerHTML).not.toContain('第三段')
|
|
167
|
+
|
|
168
|
+
// The trailing render inside the window catches up to the newest text,
|
|
169
|
+
// byte-identical to a full renderMarkdown of the accumulated text.
|
|
170
|
+
await act(async () => {
|
|
171
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
|
|
172
|
+
})
|
|
173
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
|
|
174
|
+
expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(3)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('renders the exact final HTML immediately when the turn closes', async () => {
|
|
178
|
+
const mux = new FakeMux()
|
|
179
|
+
await act(async () => {
|
|
180
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// The final event carries the authoritative text and closes the turn
|
|
184
|
+
// (pending -> false) in the same synchronous burst as the chunks.
|
|
185
|
+
const finalText = '最终 **整篇** `渲染` 与 [链接](https://example.com/x) 文本。'
|
|
186
|
+
await act(async () => {
|
|
187
|
+
chunk(mux, '流式前缀', 6)
|
|
188
|
+
chunk(mux, '中间过程', 7)
|
|
189
|
+
finalMessage(mux, finalText, 8)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// No timer advance needed: the closed turn already renders the exact
|
|
193
|
+
// final HTML (terminal messages keep the immediate-render behavior).
|
|
194
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(finalText)))
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('keeps the streaming render fresh after window edges and skips the identical final parse', async () => {
|
|
198
|
+
const mux = new FakeMux()
|
|
199
|
+
await act(async () => {
|
|
200
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const first = '第一期内容。'
|
|
204
|
+
const second = '第二期内容,包含 `code`。'
|
|
205
|
+
const full = first + second
|
|
206
|
+
|
|
207
|
+
// The chunk and the window edge in one tick: the trailing render fires
|
|
208
|
+
// inside the window and shows the current text.
|
|
209
|
+
await act(async () => {
|
|
210
|
+
chunk(mux, first, 6)
|
|
211
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
|
|
212
|
+
})
|
|
213
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(first)))
|
|
214
|
+
|
|
215
|
+
// A later chunk schedules a new trailing render; before it fires the last
|
|
216
|
+
// parsed result is still on screen.
|
|
217
|
+
await act(async () => { chunk(mux, second, 7) })
|
|
218
|
+
expect(body().innerHTML).not.toContain('第二期内容')
|
|
219
|
+
await act(async () => {
|
|
220
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
|
|
221
|
+
})
|
|
222
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
|
|
223
|
+
|
|
224
|
+
// Closing the turn with the exact text already rendered must not re-parse
|
|
225
|
+
// (the row already holds the byte-identical final HTML).
|
|
226
|
+
const beforeFinal = renderMarkdownMock.mock.calls.length
|
|
227
|
+
await act(async () => { finalMessage(mux, full, 9) })
|
|
228
|
+
expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0)
|
|
229
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -16,7 +16,8 @@ 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 } from '../api.ts'
|
|
19
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, fetchPending, respondApproval, respondQuestion } from '../api.ts'
|
|
20
|
+
import type { PendingApproval, PendingQuestionItem } from '../api.ts'
|
|
20
21
|
import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
21
22
|
import { renderMarkdown } from '../markdown.ts'
|
|
22
23
|
import { MuxClient } from '../mux.ts'
|
|
@@ -164,6 +165,12 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
164
165
|
* the legacy Enter-to-send behavior until the preference loads).
|
|
165
166
|
*/
|
|
166
167
|
const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
|
|
168
|
+
/** Whether the assistant is currently generating (turn/start..turn/end). */
|
|
169
|
+
const [running, setRunning] = useState(false)
|
|
170
|
+
/** Pending tool approvals awaiting user decision (#1025). */
|
|
171
|
+
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([])
|
|
172
|
+
/** Pending questions awaiting user answer (#1025). */
|
|
173
|
+
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestionItem[]>([])
|
|
167
174
|
|
|
168
175
|
// Read-only mobile display preferences ride the plugin's local
|
|
169
176
|
// `/m/api` method; a failure keeps the default (Enter sends).
|
|
@@ -268,6 +275,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
268
275
|
if (frame.type === 'session/event') {
|
|
269
276
|
if (frame.sessionId !== session.sessionId) return
|
|
270
277
|
const event = frame.event as WireEvent
|
|
278
|
+
// Track the turn running state for the "outputting" indicator (#1017).
|
|
279
|
+
if (typeof event.type === 'string') {
|
|
280
|
+
if (event.type === 'turn/start') setRunning(true)
|
|
281
|
+
if (event.type === 'turn/end') setRunning(false)
|
|
282
|
+
}
|
|
271
283
|
if (tailLoadingRef.current) {
|
|
272
284
|
if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
|
|
273
285
|
// Bound the tail-load window: drop the oldest buffered event and
|
|
@@ -295,10 +307,62 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
295
307
|
&& frame.sessionId === session.sessionId
|
|
296
308
|
&& frame.key === 'permissions') {
|
|
297
309
|
setPermissions(parsePermissionSelect(frame.value))
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
// Approval/question frames for this session (#1025).
|
|
313
|
+
if (!('sessionId' in frame) || frame.sessionId !== session.sessionId) return
|
|
314
|
+
if (frame.type === 'approval/requested') {
|
|
315
|
+
setPendingApprovals(previous => {
|
|
316
|
+
if (previous.some(a => a.approvalId === frame.approvalId)) return previous
|
|
317
|
+
return [...previous, {
|
|
318
|
+
approvalId: frame.approvalId as string,
|
|
319
|
+
toolName: frame.toolName,
|
|
320
|
+
callId: frame.callId as string | undefined,
|
|
321
|
+
reason: frame.reason,
|
|
322
|
+
}]
|
|
323
|
+
})
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (frame.type === 'approval/resolved') {
|
|
327
|
+
setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (frame.type === 'question/requested') {
|
|
331
|
+
const items = (frame.questions as Array<{
|
|
332
|
+
id: string; question: string; detail?: string; header?: string
|
|
333
|
+
options?: Array<{ label: string; description?: string }>; multiSelect?: boolean
|
|
334
|
+
}>)
|
|
335
|
+
setPendingQuestions(items)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
if (frame.type === 'question/resolved') {
|
|
339
|
+
setPendingQuestions([])
|
|
340
|
+
return
|
|
298
341
|
}
|
|
299
342
|
})
|
|
300
343
|
}, [mux, session.sessionId])
|
|
301
344
|
|
|
345
|
+
// Weak-network polling fallback: when the assistant is running, poll for
|
|
346
|
+
// pending approvals/questions every 1.5 s so the phone can act even if the
|
|
347
|
+
// SSE channel drops frames (#1025).
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (!running) return
|
|
350
|
+
let cancelled = false
|
|
351
|
+
const tick = (): void => {
|
|
352
|
+
void fetchPending(session.sessionId).then(
|
|
353
|
+
(state) => {
|
|
354
|
+
if (cancelled) return
|
|
355
|
+
setPendingApprovals(state.approvals)
|
|
356
|
+
setPendingQuestions(state.questions)
|
|
357
|
+
},
|
|
358
|
+
() => { /* transient; next tick retries */ },
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
tick()
|
|
362
|
+
const timer = setInterval(tick, 1500)
|
|
363
|
+
return () => { cancelled = true; clearInterval(timer) }
|
|
364
|
+
}, [running, session.sessionId])
|
|
365
|
+
|
|
302
366
|
const scrollToBottom = useCallback(() => {
|
|
303
367
|
const el = scrollRef.current
|
|
304
368
|
if (el === undefined) return
|
|
@@ -424,6 +488,26 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
424
488
|
))}
|
|
425
489
|
{loading && messages.length === 0 && <p className="chat-typing">加载中…</p>}
|
|
426
490
|
{!loading && messages.length === 0 && <p className="chat-typing">还没有消息,发一句话开始吧</p>}
|
|
491
|
+
{running && (
|
|
492
|
+
<div className="chat-turn-status" role="status" aria-label="输出中">
|
|
493
|
+
输出中<span className="chat-turn-dots" aria-hidden><span /><span /><span /></span>
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
{pendingApprovals.map(approval => (
|
|
497
|
+
<ApprovalPanel
|
|
498
|
+
key={approval.approvalId}
|
|
499
|
+
approval={approval}
|
|
500
|
+
sessionId={session.sessionId}
|
|
501
|
+
onResolved={(id) => { setPendingApprovals(prev => prev.filter(a => a.approvalId !== id)) }}
|
|
502
|
+
/>
|
|
503
|
+
))}
|
|
504
|
+
{pendingQuestions.length > 0 && (
|
|
505
|
+
<QuestionPanel
|
|
506
|
+
questions={pendingQuestions}
|
|
507
|
+
sessionId={session.sessionId}
|
|
508
|
+
onResolved={() => { setPendingQuestions([]) }}
|
|
509
|
+
/>
|
|
510
|
+
)}
|
|
427
511
|
</div>
|
|
428
512
|
<div className="chat-tools">
|
|
429
513
|
<button type="button" className="chat-chip" onClick={() => { setSheet('model') }} aria-haspopup="dialog">
|
|
@@ -527,7 +611,7 @@ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystem
|
|
|
527
611
|
<ToolDisclosure tools={message.tools} />
|
|
528
612
|
)}
|
|
529
613
|
{message.kind === 'assistant'
|
|
530
|
-
? <MarkdownText text={message.text} />
|
|
614
|
+
? <MarkdownText text={message.text} pending={message.pending === true} />
|
|
531
615
|
: <CollapsibleText text={message.text} />}
|
|
532
616
|
{message.failed === true && <span className="chat-msg-failtag">本次回复失败</span>}
|
|
533
617
|
<span className="chat-msg-time">{formatTime(message.time)}</span>
|
|
@@ -556,10 +640,10 @@ function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean
|
|
|
556
640
|
)
|
|
557
641
|
}
|
|
558
642
|
|
|
559
|
-
/** Collapsed-by-default tool-call disclosure: summary
|
|
643
|
+
/** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
|
|
560
644
|
function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
|
|
561
645
|
const [open, setOpen] = useState(false)
|
|
562
|
-
const
|
|
646
|
+
const uniqueNames = [...new Set(tools.map(tool => tool.name))]
|
|
563
647
|
return (
|
|
564
648
|
<div className={`chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`}>
|
|
565
649
|
<button
|
|
@@ -570,15 +654,23 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
|
|
|
570
654
|
>
|
|
571
655
|
<span className="chat-disclosure-caret" aria-hidden>›</span>
|
|
572
656
|
<span className="chat-disclosure-label">工具</span>
|
|
573
|
-
{!open &&
|
|
657
|
+
{!open && (
|
|
658
|
+
<span className="chat-disclosure-summary chat-tool-pills">
|
|
659
|
+
{uniqueNames.map(name => (
|
|
660
|
+
<span key={name} className="chat-tool-pill">{name}</span>
|
|
661
|
+
))}
|
|
662
|
+
</span>
|
|
663
|
+
)}
|
|
574
664
|
<span className="chat-disclosure-count">{tools.length} 次</span>
|
|
575
665
|
</button>
|
|
576
666
|
{open && (
|
|
577
667
|
<div className="chat-disclosure-body chat-tools-body">
|
|
578
668
|
{tools.map((tool, index) => (
|
|
579
|
-
<div className="chat-tool-
|
|
580
|
-
<span className="chat-tool-
|
|
581
|
-
{tool.arguments !== undefined &&
|
|
669
|
+
<div className="chat-tool-card" key={`${tool.callId}-${index}`}>
|
|
670
|
+
<span className="chat-tool-pill">{tool.name}</span>
|
|
671
|
+
{tool.arguments !== undefined && (
|
|
672
|
+
<pre className="chat-tool-args">{tool.arguments}</pre>
|
|
673
|
+
)}
|
|
582
674
|
</div>
|
|
583
675
|
))}
|
|
584
676
|
</div>
|
|
@@ -587,6 +679,17 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
|
|
|
587
679
|
)
|
|
588
680
|
}
|
|
589
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Minimum interval between full markdown re-parses of a live (pending)
|
|
684
|
+
* assistant message. Every streamed chunk replaces the message object, and
|
|
685
|
+
* re-parsing the whole accumulated text per chunk turns a long reply into
|
|
686
|
+
* O(n^2) work on mobile. Pending text keeps the last parsed result visible
|
|
687
|
+
* and re-parses at most once per interval; the moment the turn closes the
|
|
688
|
+
* final text parses immediately, so terminal messages render exactly as
|
|
689
|
+
* before.
|
|
690
|
+
*/
|
|
691
|
+
export const STREAM_RENDER_INTERVAL_MS = 120
|
|
692
|
+
|
|
590
693
|
/**
|
|
591
694
|
* Assistant text rendered as GFM markdown (escape-first, protocol
|
|
592
695
|
* allow-list — see markdown.ts). Long replies collapse by clamping the
|
|
@@ -594,9 +697,59 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
|
|
|
594
697
|
* fences or tables never leak malformed markup into the DOM. User
|
|
595
698
|
* messages stay plain text (CollapsibleText).
|
|
596
699
|
*/
|
|
597
|
-
function MarkdownText({ text }: { text: string }) {
|
|
700
|
+
function MarkdownText({ text, pending }: { text: string; pending: boolean }) {
|
|
598
701
|
const [open, setOpen] = useState(false)
|
|
599
|
-
const html =
|
|
702
|
+
const [html, setHtml] = useState<string>(() => renderMarkdown(text))
|
|
703
|
+
/** Text of the last render actually applied to `html`. */
|
|
704
|
+
const renderedTextRef = useRef(text)
|
|
705
|
+
/** Newest streamed text, read by the trailing render at fire time. */
|
|
706
|
+
const latestTextRef = useRef(text)
|
|
707
|
+
/** Timestamp of the last applied parse (throttle window start). */
|
|
708
|
+
const lastRenderAtRef = useRef(performance.now())
|
|
709
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
710
|
+
|
|
711
|
+
// Throttled parse for a live stream: skip parses while the newest text is
|
|
712
|
+
// already rendered, parse immediately once the throttle window elapsed,
|
|
713
|
+
// otherwise schedule one trailing render that picks up the newest text.
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
latestTextRef.current = text
|
|
716
|
+
if (!pending) {
|
|
717
|
+
// Turn closed: terminal messages are never throttled, so cancel any
|
|
718
|
+
// scheduled stream render and parse the final text immediately.
|
|
719
|
+
if (timerRef.current !== undefined) {
|
|
720
|
+
clearTimeout(timerRef.current)
|
|
721
|
+
timerRef.current = undefined
|
|
722
|
+
}
|
|
723
|
+
if (text === renderedTextRef.current) return
|
|
724
|
+
lastRenderAtRef.current = performance.now()
|
|
725
|
+
renderedTextRef.current = text
|
|
726
|
+
setHtml(renderMarkdown(text))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
if (text === renderedTextRef.current) return
|
|
730
|
+
const elapsed = performance.now() - lastRenderAtRef.current
|
|
731
|
+
if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
|
|
732
|
+
lastRenderAtRef.current = performance.now()
|
|
733
|
+
renderedTextRef.current = text
|
|
734
|
+
setHtml(renderMarkdown(text))
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (timerRef.current === undefined) {
|
|
738
|
+
timerRef.current = setTimeout(() => {
|
|
739
|
+
timerRef.current = undefined
|
|
740
|
+
lastRenderAtRef.current = performance.now()
|
|
741
|
+
renderedTextRef.current = latestTextRef.current
|
|
742
|
+
setHtml(renderMarkdown(latestTextRef.current))
|
|
743
|
+
}, STREAM_RENDER_INTERVAL_MS - elapsed)
|
|
744
|
+
}
|
|
745
|
+
}, [text, pending])
|
|
746
|
+
|
|
747
|
+
// Cancel the trailing stream render if the row unmounts mid-stream.
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
return () => {
|
|
750
|
+
if (timerRef.current !== undefined) clearTimeout(timerRef.current)
|
|
751
|
+
}
|
|
752
|
+
}, [])
|
|
600
753
|
const long = text.length > LONG_TEXT_LIMIT
|
|
601
754
|
const collapsed = long && !open
|
|
602
755
|
return (
|
|
@@ -937,3 +1090,168 @@ function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystem
|
|
|
937
1090
|
</Sheet>
|
|
938
1091
|
)
|
|
939
1092
|
}
|
|
1093
|
+
|
|
1094
|
+
/* ── approval / question panels (#1025) ──────────────────────────────── */
|
|
1095
|
+
|
|
1096
|
+
/** One pending tool approval card with allow/reject actions. */
|
|
1097
|
+
function ApprovalPanel({ approval, sessionId, onResolved }: {
|
|
1098
|
+
approval: PendingApproval
|
|
1099
|
+
sessionId: string
|
|
1100
|
+
onResolved(approvalId: string): void
|
|
1101
|
+
}) {
|
|
1102
|
+
const [busy, setBusy] = useState(false)
|
|
1103
|
+
const [panelError, setPanelError] = useState<string | undefined>(undefined)
|
|
1104
|
+
|
|
1105
|
+
const act = (outcome: 'allowed-once' | 'rejected'): void => {
|
|
1106
|
+
if (busy) return
|
|
1107
|
+
setBusy(true)
|
|
1108
|
+
setPanelError(undefined)
|
|
1109
|
+
void respondApproval(sessionId, approval.approvalId, outcome).then(
|
|
1110
|
+
() => { onResolved(approval.approvalId) },
|
|
1111
|
+
(reason: unknown) => {
|
|
1112
|
+
setBusy(false)
|
|
1113
|
+
setPanelError(reason instanceof Error ? reason.message : String(reason))
|
|
1114
|
+
},
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return (
|
|
1119
|
+
<div className="chat-approval-panel" role="alert">
|
|
1120
|
+
<div className="chat-approval-header">
|
|
1121
|
+
<span className="chat-tool-pill">{approval.toolName}</span>
|
|
1122
|
+
{approval.reason !== undefined && (
|
|
1123
|
+
<span className="chat-approval-reason">{approval.reason}</span>
|
|
1124
|
+
)}
|
|
1125
|
+
</div>
|
|
1126
|
+
{panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
|
|
1127
|
+
<div className="chat-approval-actions">
|
|
1128
|
+
<button
|
|
1129
|
+
type="button"
|
|
1130
|
+
className="chat-approval-allow"
|
|
1131
|
+
disabled={busy}
|
|
1132
|
+
onClick={() => { act('allowed-once') }}
|
|
1133
|
+
>
|
|
1134
|
+
{busy ? '提交中…' : '允许一次'}
|
|
1135
|
+
</button>
|
|
1136
|
+
<button
|
|
1137
|
+
type="button"
|
|
1138
|
+
className="chat-approval-reject"
|
|
1139
|
+
disabled={busy}
|
|
1140
|
+
onClick={() => { act('rejected') }}
|
|
1141
|
+
>
|
|
1142
|
+
拒绝
|
|
1143
|
+
</button>
|
|
1144
|
+
</div>
|
|
1145
|
+
</div>
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Question panel: renders one or more questions with option pickers and a submit button. */
|
|
1150
|
+
function QuestionPanel({ questions, sessionId, onResolved }: {
|
|
1151
|
+
questions: PendingQuestionItem[]
|
|
1152
|
+
sessionId: string
|
|
1153
|
+
onResolved(): void
|
|
1154
|
+
}) {
|
|
1155
|
+
const [selections, setSelections] = useState<Map<string, { selected: string[]; custom: string }>>(
|
|
1156
|
+
() => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])),
|
|
1157
|
+
)
|
|
1158
|
+
const [busy, setBusy] = useState(false)
|
|
1159
|
+
const [panelError, setPanelError] = useState<string | undefined>(undefined)
|
|
1160
|
+
|
|
1161
|
+
const toggle = (questionId: string, label: string, multi: boolean): void => {
|
|
1162
|
+
setSelections(previous => {
|
|
1163
|
+
const next = new Map(previous)
|
|
1164
|
+
const entry = next.get(questionId) ?? { selected: [], custom: '' }
|
|
1165
|
+
if (multi) {
|
|
1166
|
+
const set = new Set(entry.selected)
|
|
1167
|
+
if (set.has(label)) set.delete(label); else set.add(label)
|
|
1168
|
+
next.set(questionId, { ...entry, selected: [...set] })
|
|
1169
|
+
} else {
|
|
1170
|
+
next.set(questionId, { ...entry, selected: [label] })
|
|
1171
|
+
}
|
|
1172
|
+
return next
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const setCustom = (questionId: string, value: string): void => {
|
|
1177
|
+
setSelections(previous => {
|
|
1178
|
+
const next = new Map(previous)
|
|
1179
|
+
const entry = next.get(questionId) ?? { selected: [], custom: '' }
|
|
1180
|
+
next.set(questionId, { ...entry, custom: value })
|
|
1181
|
+
return next
|
|
1182
|
+
})
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const submit = (): void => {
|
|
1186
|
+
if (busy) return
|
|
1187
|
+
setBusy(true)
|
|
1188
|
+
setPanelError(undefined)
|
|
1189
|
+
const answers = questions.map(q => {
|
|
1190
|
+
const entry = selections.get(q.id) ?? { selected: [], custom: '' }
|
|
1191
|
+
return {
|
|
1192
|
+
id: q.id,
|
|
1193
|
+
selected: entry.selected,
|
|
1194
|
+
...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
|
|
1195
|
+
}
|
|
1196
|
+
})
|
|
1197
|
+
void respondQuestion(sessionId, answers).then(
|
|
1198
|
+
() => { onResolved() },
|
|
1199
|
+
(reason: unknown) => {
|
|
1200
|
+
setBusy(false)
|
|
1201
|
+
setPanelError(reason instanceof Error ? reason.message : String(reason))
|
|
1202
|
+
},
|
|
1203
|
+
)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return (
|
|
1207
|
+
<div className="chat-question-panel" role="form" aria-label="问题">
|
|
1208
|
+
{questions.map(q => {
|
|
1209
|
+
const entry = selections.get(q.id) ?? { selected: [], custom: '' }
|
|
1210
|
+
return (
|
|
1211
|
+
<div className="chat-question-group" key={q.id}>
|
|
1212
|
+
{q.header !== undefined && <div className="chat-question-header">{q.header}</div>}
|
|
1213
|
+
<div className="chat-question-text">{q.question}</div>
|
|
1214
|
+
{q.detail !== undefined && <div className="chat-question-detail">{q.detail}</div>}
|
|
1215
|
+
{q.options !== undefined && q.options.length > 0 && (
|
|
1216
|
+
<div className="chat-question-options" role="group" aria-label={q.question}>
|
|
1217
|
+
{q.options.map(option => {
|
|
1218
|
+
const checked = entry.selected.includes(option.label)
|
|
1219
|
+
return (
|
|
1220
|
+
<label key={option.label} className={`chat-question-option${checked ? ' chat-question-option-selected' : ''}`}>
|
|
1221
|
+
<input
|
|
1222
|
+
type={q.multiSelect ? 'checkbox' : 'radio'}
|
|
1223
|
+
name={`q-${q.id}`}
|
|
1224
|
+
checked={checked}
|
|
1225
|
+
onChange={() => { toggle(q.id, option.label, q.multiSelect === true) }}
|
|
1226
|
+
/>
|
|
1227
|
+
<span className="chat-question-option-label">{option.label}</span>
|
|
1228
|
+
{option.description !== undefined && (
|
|
1229
|
+
<span className="chat-question-option-desc">{option.description}</span>
|
|
1230
|
+
)}
|
|
1231
|
+
</label>
|
|
1232
|
+
)
|
|
1233
|
+
})}
|
|
1234
|
+
</div>
|
|
1235
|
+
)}
|
|
1236
|
+
<textarea
|
|
1237
|
+
className="chat-question-custom"
|
|
1238
|
+
placeholder="自定义回答(可选)"
|
|
1239
|
+
rows={2}
|
|
1240
|
+
value={entry.custom}
|
|
1241
|
+
onChange={(e) => { setCustom(q.id, e.target.value) }}
|
|
1242
|
+
/>
|
|
1243
|
+
</div>
|
|
1244
|
+
)
|
|
1245
|
+
})}
|
|
1246
|
+
{panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
|
|
1247
|
+
<button
|
|
1248
|
+
type="button"
|
|
1249
|
+
className="chat-question-submit"
|
|
1250
|
+
disabled={busy}
|
|
1251
|
+
onClick={submit}
|
|
1252
|
+
>
|
|
1253
|
+
{busy ? '提交中…' : '提交回答'}
|
|
1254
|
+
</button>
|
|
1255
|
+
</div>
|
|
1256
|
+
)
|
|
1257
|
+
}
|