@linxin666/dsh-remote-web-ui 0.1.17 → 0.1.18
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 +5 -2
- package/README.zh.md +2 -2
- package/lib/client.js +48 -58
- package/lib/client.js.map +1 -1
- package/lib/mobile.js +649 -45
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +11 -5
- package/lib/types/mobile/markdown.d.ts +26 -0
- package/lib/types/mobile/markdown.d.ts.map +1 -0
- package/lib/types/mobile/markdown.js +272 -0
- package/lib/types/mobile/markdown.test.d.ts +2 -0
- package/lib/types/mobile/markdown.test.d.ts.map +1 -0
- package/lib/types/mobile/markdown.test.js +67 -0
- package/lib/types/mobile/messages.d.ts +14 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +54 -2
- package/lib/types/mobile/messages.test.js +99 -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 +167 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +81 -5
- package/lib/types/mobile/views/ChatView.test.js +105 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/settings-card.module.css +9 -0
- package/src/mobile/markdown.test.ts +77 -0
- package/src/mobile/markdown.ts +278 -0
- package/src/mobile/messages.test.ts +106 -0
- package/src/mobile/messages.ts +66 -2
- package/src/mobile/mobile-styles.ts +167 -0
- package/src/mobile/views/ChatView.test.tsx +121 -0
- package/src/mobile/views/ChatView.tsx +164 -6
|
@@ -219,4 +219,110 @@ describe('foldEvents', () => {
|
|
|
219
219
|
foldEvents([makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, 'world'), 1)], first)
|
|
220
220
|
expect(JSON.stringify(first)).toBe(snapshot)
|
|
221
221
|
})
|
|
222
|
+
|
|
223
|
+
it('request/context sets the context window used by a later assistant/message with usage', () => {
|
|
224
|
+
const events: WireEvent[] = [
|
|
225
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
226
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
|
|
227
|
+
makeEvent('assistant/message', {
|
|
228
|
+
turn: 0,
|
|
229
|
+
step: 0,
|
|
230
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
231
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
232
|
+
}, 2),
|
|
233
|
+
]
|
|
234
|
+
const result = foldEvents(events)
|
|
235
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
236
|
+
expect(assistant).toMatchObject({
|
|
237
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
238
|
+
contextWindow: 100_000,
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('usage without a context window carries no contextWindow', () => {
|
|
243
|
+
const events: WireEvent[] = [
|
|
244
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
245
|
+
makeEvent('assistant/message', {
|
|
246
|
+
turn: 0,
|
|
247
|
+
step: 0,
|
|
248
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
249
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
250
|
+
}, 1),
|
|
251
|
+
]
|
|
252
|
+
const result = foldEvents(events)
|
|
253
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
254
|
+
expect(assistant).toMatchObject({ usage: { inputTokens: 10, outputTokens: 5 } })
|
|
255
|
+
expect(assistant?.contextWindow).toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('usage and contextWindow are both attached when request/context precedes the message', () => {
|
|
259
|
+
const events: WireEvent[] = [
|
|
260
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 200_000 }, 0),
|
|
261
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
|
|
262
|
+
makeEvent('assistant/message', {
|
|
263
|
+
turn: 0,
|
|
264
|
+
step: 0,
|
|
265
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
266
|
+
usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
|
|
267
|
+
}, 2),
|
|
268
|
+
]
|
|
269
|
+
const result = foldEvents(events)
|
|
270
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
271
|
+
expect(assistant).toMatchObject({
|
|
272
|
+
usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
|
|
273
|
+
contextWindow: 200_000,
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('assistant/message without usage has no usage field', () => {
|
|
278
|
+
const events: WireEvent[] = [
|
|
279
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
280
|
+
makeEvent('assistant/message', { turn: 0, step: 0, message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }, 1),
|
|
281
|
+
]
|
|
282
|
+
const result = foldEvents(events)
|
|
283
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
284
|
+
expect(assistant?.usage).toBeUndefined()
|
|
285
|
+
expect(assistant?.contextWindow).toBeUndefined()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('attaches sourceKind from source.kind for a user message', () => {
|
|
289
|
+
const events: WireEvent[] = [
|
|
290
|
+
makeEvent('user/message', {
|
|
291
|
+
id: 'u-plugin',
|
|
292
|
+
role: 'user',
|
|
293
|
+
content: [{ type: 'text', text: '系统注入' }],
|
|
294
|
+
source: { kind: 'plugin', name: 'react-extension' },
|
|
295
|
+
}, 0),
|
|
296
|
+
]
|
|
297
|
+
const result = foldEvents(events)
|
|
298
|
+
expect(result[0]).toMatchObject({ id: 'u-plugin', kind: 'user', sourceKind: 'plugin' })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('keeps sourceKind on a replayed/replaced user/message', () => {
|
|
302
|
+
const first = foldEvents([makeEvent('user/message', {
|
|
303
|
+
id: 'u-1',
|
|
304
|
+
role: 'user',
|
|
305
|
+
content: [{ type: 'text', text: '第一版' }],
|
|
306
|
+
source: { kind: 'plugin' },
|
|
307
|
+
}, 0)])
|
|
308
|
+
const second = foldEvents([makeEvent('user/message', {
|
|
309
|
+
id: 'u-1',
|
|
310
|
+
role: 'user',
|
|
311
|
+
content: [{ type: 'text', text: '第二版' }],
|
|
312
|
+
source: { kind: 'plugin' },
|
|
313
|
+
}, 1)], first)
|
|
314
|
+
expect(second[0]).toMatchObject({ id: 'u-1', text: '第二版', sourceKind: 'plugin' })
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('request/context and unknown events are still ignored by the fold', () => {
|
|
318
|
+
const events: WireEvent[] = [
|
|
319
|
+
makeEvent('some/future-event', { nope: true }, 0),
|
|
320
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1' }, 1),
|
|
321
|
+
makeEvent('user/message', userMessageData('u-1', '真实消息'), 2),
|
|
322
|
+
]
|
|
323
|
+
const result = foldEvents(events)
|
|
324
|
+
// request/context with no window and the unknown event render nothing.
|
|
325
|
+
expect(result).toHaveLength(1)
|
|
326
|
+
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
|
|
327
|
+
})
|
|
222
328
|
})
|
package/src/mobile/messages.ts
CHANGED
|
@@ -68,6 +68,20 @@ export interface RenderMessage {
|
|
|
68
68
|
readonly toolSummary?: string
|
|
69
69
|
/** Set when the owning turn ended in an error. */
|
|
70
70
|
readonly failed?: boolean
|
|
71
|
+
/**
|
|
72
|
+
* Token usage reported by the final assistant event. cacheReadTokens and
|
|
73
|
+
* cacheWriteTokens are only attached when the wire carried finite values.
|
|
74
|
+
*/
|
|
75
|
+
readonly usage?: {
|
|
76
|
+
inputTokens: number
|
|
77
|
+
outputTokens: number
|
|
78
|
+
cacheReadTokens?: number
|
|
79
|
+
cacheWriteTokens?: number
|
|
80
|
+
}
|
|
81
|
+
/** Context window for the model that produced this message (from request/context). */
|
|
82
|
+
readonly contextWindow?: number
|
|
83
|
+
/** Wire source.kind of a user message (e.g. plugin or user). */
|
|
84
|
+
readonly sourceKind?: string
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
/** One tool call attached to an assistant message (callId dedupes repeats). */
|
|
@@ -199,6 +213,8 @@ interface FoldState {
|
|
|
199
213
|
messageTurn: Map<string, number>
|
|
200
214
|
/** Deduped tool names per assistant message id. */
|
|
201
215
|
toolNames: Map<string, Set<string>>
|
|
216
|
+
/** Context window for the current model (from request/context). */
|
|
217
|
+
contextWindow?: number
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
function createState(existing: readonly RenderMessage[] | undefined): FoldState {
|
|
@@ -294,6 +310,14 @@ function applyEvent(state: FoldState, event: WireEvent): void {
|
|
|
294
310
|
case 'tool/call':
|
|
295
311
|
applyToolCall(state, event)
|
|
296
312
|
break
|
|
313
|
+
case 'request/context': {
|
|
314
|
+
// Wire shape: { provider, model, contextWindow? }. A present finite
|
|
315
|
+
// contextWindow seeds every later assistant message that reports usage.
|
|
316
|
+
const data = isRecord(event.data) ? event.data : {}
|
|
317
|
+
const window = pickNumber(data['contextWindow'])
|
|
318
|
+
if (window !== undefined) state.contextWindow = window
|
|
319
|
+
break
|
|
320
|
+
}
|
|
297
321
|
// turn/start, session/end-seed, and every other/unknown type render nothing.
|
|
298
322
|
default:
|
|
299
323
|
break
|
|
@@ -304,13 +328,28 @@ function applyUserMessage(state: FoldState, event: WireEvent): void {
|
|
|
304
328
|
const data = isRecord(event.data) ? event.data : {}
|
|
305
329
|
const id = pickString(data['id']) ?? syntheticId('user', event.seq)
|
|
306
330
|
const text = textFromContent(data['content'])
|
|
331
|
+
const source = isRecord(data['source']) ? data['source'] : {}
|
|
332
|
+
const sourceKind = pickString(source['kind'])
|
|
307
333
|
const existing = state.byId.get(id)
|
|
308
334
|
if (existing !== undefined) {
|
|
309
335
|
// Idempotent replace (replayed events update in place, never duplicate).
|
|
310
|
-
replaceMessage(state, existing, {
|
|
336
|
+
replaceMessage(state, existing, {
|
|
337
|
+
...existing,
|
|
338
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
339
|
+
text,
|
|
340
|
+
seq: event.seq,
|
|
341
|
+
time: event.time,
|
|
342
|
+
})
|
|
311
343
|
return
|
|
312
344
|
}
|
|
313
|
-
const message: RenderMessage = {
|
|
345
|
+
const message: RenderMessage = {
|
|
346
|
+
id,
|
|
347
|
+
kind: 'user',
|
|
348
|
+
text,
|
|
349
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
350
|
+
seq: event.seq,
|
|
351
|
+
time: event.time,
|
|
352
|
+
}
|
|
314
353
|
state.messages.push(message)
|
|
315
354
|
state.byId.set(id, message)
|
|
316
355
|
}
|
|
@@ -324,6 +363,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
324
363
|
const finalText = textFromContent(messageData['content'])
|
|
325
364
|
const finalReasoning = reasoningFromContent(messageData['content'])
|
|
326
365
|
const key = tsKey(turn, step)
|
|
366
|
+
const usage = usageFromData(data)
|
|
367
|
+
const contextWindow = state.contextWindow
|
|
327
368
|
|
|
328
369
|
// Finalize the matching assistant message (by id, or by turn/step for the
|
|
329
370
|
// streaming partial that chunks built before the final event arrived).
|
|
@@ -338,6 +379,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
338
379
|
// The final content block list is authoritative; an adapter that omits
|
|
339
380
|
// reasoning from the final message keeps the streamed reasoning text.
|
|
340
381
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
382
|
+
...(usage !== undefined ? { usage } : {}),
|
|
383
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
341
384
|
seq: event.seq,
|
|
342
385
|
time: event.time,
|
|
343
386
|
pending: false,
|
|
@@ -353,6 +396,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
353
396
|
kind: 'assistant',
|
|
354
397
|
text: finalText,
|
|
355
398
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
399
|
+
...(usage !== undefined ? { usage } : {}),
|
|
400
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
356
401
|
seq: event.seq,
|
|
357
402
|
time: event.time,
|
|
358
403
|
}
|
|
@@ -365,6 +410,25 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
365
410
|
if (turn !== undefined) state.messageTurn.set(id, turn)
|
|
366
411
|
}
|
|
367
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Extract token usage from an assistant event payload. Only attaches when the
|
|
415
|
+
* wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
|
|
416
|
+
* included only for finite numbers.
|
|
417
|
+
*/
|
|
418
|
+
function usageFromData(data: Record<string, unknown>): { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined {
|
|
419
|
+
const usageData = data['usage']
|
|
420
|
+
if (!isRecord(usageData)) return undefined
|
|
421
|
+
const inputTokens = pickNumber(usageData['inputTokens'])
|
|
422
|
+
const outputTokens = pickNumber(usageData['outputTokens'])
|
|
423
|
+
if (inputTokens === undefined || outputTokens === undefined) return undefined
|
|
424
|
+
const usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } = { inputTokens, outputTokens }
|
|
425
|
+
const cacheReadTokens = pickNumber(usageData['cacheReadTokens'])
|
|
426
|
+
const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens'])
|
|
427
|
+
if (cacheReadTokens !== undefined) usage.cacheReadTokens = cacheReadTokens
|
|
428
|
+
if (cacheWriteTokens !== undefined) usage.cacheWriteTokens = cacheWriteTokens
|
|
429
|
+
return usage
|
|
430
|
+
}
|
|
431
|
+
|
|
368
432
|
function applyChunk(state: FoldState, event: WireEvent): void {
|
|
369
433
|
const target = chunkTarget(event.data)
|
|
370
434
|
if (target === null) return
|
|
@@ -393,6 +393,85 @@ body {
|
|
|
393
393
|
cursor: pointer;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
/* Assistant markdown content (GFM subset; renderer in markdown.ts). */
|
|
397
|
+
.chat-md {
|
|
398
|
+
white-space: normal;
|
|
399
|
+
}
|
|
400
|
+
.chat-md-collapsed {
|
|
401
|
+
max-height: 45vh;
|
|
402
|
+
overflow: hidden;
|
|
403
|
+
position: relative;
|
|
404
|
+
}
|
|
405
|
+
.chat-md-collapsed::after {
|
|
406
|
+
content: '';
|
|
407
|
+
position: absolute;
|
|
408
|
+
left: 0;
|
|
409
|
+
right: 0;
|
|
410
|
+
bottom: 0;
|
|
411
|
+
height: 28px;
|
|
412
|
+
background: linear-gradient(transparent, var(--m-bg-raised));
|
|
413
|
+
pointer-events: none;
|
|
414
|
+
}
|
|
415
|
+
.chat-md-body p { margin: 0 0 8px; }
|
|
416
|
+
.chat-md-body p:last-child { margin-bottom: 0; }
|
|
417
|
+
.chat-md-body h1, .chat-md-body h2, .chat-md-body h3,
|
|
418
|
+
.chat-md-body h4, .chat-md-body h5, .chat-md-body h6 {
|
|
419
|
+
margin: 12px 0 6px;
|
|
420
|
+
font-weight: 650;
|
|
421
|
+
line-height: 1.3;
|
|
422
|
+
}
|
|
423
|
+
.chat-md-body h1 { font-size: 1.35em; }
|
|
424
|
+
.chat-md-body h2 { font-size: 1.25em; }
|
|
425
|
+
.chat-md-body h3 { font-size: 1.15em; }
|
|
426
|
+
.chat-md-body h4, .chat-md-body h5, .chat-md-body h6 { font-size: 1.05em; }
|
|
427
|
+
.chat-md-body code {
|
|
428
|
+
font-family: var(--m-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
429
|
+
font-size: 0.9em;
|
|
430
|
+
background: var(--m-accent-soft);
|
|
431
|
+
padding: 1px 5px;
|
|
432
|
+
border-radius: 4px;
|
|
433
|
+
}
|
|
434
|
+
.chat-md-body pre {
|
|
435
|
+
margin: 8px 0;
|
|
436
|
+
padding: 10px;
|
|
437
|
+
background: #101726;
|
|
438
|
+
color: #dbe4f5;
|
|
439
|
+
border-radius: 8px;
|
|
440
|
+
overflow-x: auto;
|
|
441
|
+
font-size: 12.5px;
|
|
442
|
+
line-height: 1.5;
|
|
443
|
+
}
|
|
444
|
+
.chat-md-body pre code {
|
|
445
|
+
background: transparent;
|
|
446
|
+
padding: 0;
|
|
447
|
+
color: inherit;
|
|
448
|
+
}
|
|
449
|
+
.chat-md-body ul, .chat-md-body ol { margin: 4px 0 8px; padding-left: 22px; }
|
|
450
|
+
.chat-md-body li { margin: 2px 0; }
|
|
451
|
+
.chat-md-body blockquote {
|
|
452
|
+
margin: 8px 0;
|
|
453
|
+
padding: 4px 10px;
|
|
454
|
+
border-left: 3px solid var(--m-accent);
|
|
455
|
+
background: var(--m-accent-soft);
|
|
456
|
+
border-radius: 0 6px 6px 0;
|
|
457
|
+
color: var(--m-text-secondary);
|
|
458
|
+
}
|
|
459
|
+
.chat-md-body table {
|
|
460
|
+
margin: 8px 0;
|
|
461
|
+
border-collapse: collapse;
|
|
462
|
+
display: block;
|
|
463
|
+
overflow-x: auto;
|
|
464
|
+
font-size: 13px;
|
|
465
|
+
}
|
|
466
|
+
.chat-md-body th, .chat-md-body td {
|
|
467
|
+
border: 1px solid var(--m-border);
|
|
468
|
+
padding: 4px 8px;
|
|
469
|
+
}
|
|
470
|
+
.chat-md-body th { background: var(--m-accent-soft); }
|
|
471
|
+
.chat-md-body a { color: var(--m-accent); }
|
|
472
|
+
.chat-md-body hr { border: none; border-top: 1px solid var(--m-border); margin: 10px 0; }
|
|
473
|
+
.chat-md-body img { max-width: 100%; border-radius: 8px; }
|
|
474
|
+
|
|
396
475
|
/* ── message disclosures (reasoning / tools) ─────────────────────────── */
|
|
397
476
|
|
|
398
477
|
.chat-disclosure {
|
|
@@ -824,11 +903,97 @@ body {
|
|
|
824
903
|
.chat-disclosure-head:focus-visible,
|
|
825
904
|
.sheet-option:focus-visible,
|
|
826
905
|
.sheet-confirm-danger:focus-visible,
|
|
906
|
+
.sheet-toggle-switch:focus-visible,
|
|
827
907
|
.chat-input:focus-visible {
|
|
828
908
|
outline: none;
|
|
829
909
|
box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
|
|
830
910
|
}
|
|
831
911
|
|
|
912
|
+
/* ── chat context usage chip (chat-tools row) ───────────────────────── */
|
|
913
|
+
|
|
914
|
+
.chat-context {
|
|
915
|
+
flex: none;
|
|
916
|
+
align-self: center;
|
|
917
|
+
padding: 2px 10px;
|
|
918
|
+
border: 1px solid var(--m-border);
|
|
919
|
+
border-radius: 999px;
|
|
920
|
+
background: color-mix(in srgb, var(--m-text-tertiary) 10%, transparent);
|
|
921
|
+
color: var(--m-text-tertiary);
|
|
922
|
+
font-size: 11px;
|
|
923
|
+
line-height: 1.6;
|
|
924
|
+
white-space: nowrap;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
.chat-context-warn {
|
|
928
|
+
border-color: var(--m-danger);
|
|
929
|
+
background: color-mix(in srgb, var(--m-danger) 14%, transparent);
|
|
930
|
+
color: var(--m-danger);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/* ── display sheet toggle rows ──────────────────────────────────────── */
|
|
934
|
+
|
|
935
|
+
.sheet-toggle-row {
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
gap: 12px;
|
|
939
|
+
padding: 12px 8px;
|
|
940
|
+
border-bottom: 1px solid var(--m-border);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.sheet-toggle-row:last-child {
|
|
944
|
+
border-bottom: none;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.sheet-toggle-copy {
|
|
948
|
+
flex: 1;
|
|
949
|
+
min-width: 0;
|
|
950
|
+
display: flex;
|
|
951
|
+
flex-direction: column;
|
|
952
|
+
gap: 2px;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.sheet-toggle-title {
|
|
956
|
+
font-size: 14px;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.sheet-toggle-desc {
|
|
960
|
+
color: var(--m-text-tertiary);
|
|
961
|
+
font-size: 12px;
|
|
962
|
+
line-height: 1.4;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.sheet-toggle-switch {
|
|
966
|
+
position: relative;
|
|
967
|
+
flex: none;
|
|
968
|
+
width: 44px;
|
|
969
|
+
height: 26px;
|
|
970
|
+
border: none;
|
|
971
|
+
border-radius: 999px;
|
|
972
|
+
background: var(--m-border);
|
|
973
|
+
cursor: pointer;
|
|
974
|
+
transition: background-color 0.15s ease;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
.sheet-toggle-switch-on {
|
|
978
|
+
background: var(--m-accent);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.sheet-toggle-switch-knob {
|
|
982
|
+
position: absolute;
|
|
983
|
+
top: 3px;
|
|
984
|
+
left: 3px;
|
|
985
|
+
width: 20px;
|
|
986
|
+
height: 20px;
|
|
987
|
+
border-radius: 50%;
|
|
988
|
+
background: #fff;
|
|
989
|
+
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.2);
|
|
990
|
+
transition: transform 0.15s ease;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.sheet-toggle-switch-on .sheet-toggle-switch-knob {
|
|
994
|
+
transform: translateX(18px);
|
|
995
|
+
}
|
|
996
|
+
|
|
832
997
|
/* Accessibility: collapse every interaction/enter animation for
|
|
833
998
|
reduced-motion users; surface layout itself stays static. */
|
|
834
999
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -848,6 +1013,8 @@ body {
|
|
|
848
1013
|
.sheet,
|
|
849
1014
|
.sheet-option,
|
|
850
1015
|
.sheet-confirm-danger,
|
|
1016
|
+
.sheet-toggle-switch,
|
|
1017
|
+
.sheet-toggle-switch-knob,
|
|
851
1018
|
.chat-input {
|
|
852
1019
|
animation: none;
|
|
853
1020
|
transition: none;
|
|
@@ -518,3 +518,124 @@ describe('ChatView scrolling', () => {
|
|
|
518
518
|
await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore) })
|
|
519
519
|
})
|
|
520
520
|
})
|
|
521
|
+
|
|
522
|
+
describe('ChatView display toggles and context usage', () => {
|
|
523
|
+
/** jsdom in this setup ships a bare localStorage object; install a real fake. */
|
|
524
|
+
const makeStorage = (): Storage => {
|
|
525
|
+
const map = new Map<string, string>()
|
|
526
|
+
return {
|
|
527
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
528
|
+
setItem: (key: string, value: string) => { map.set(key, value) },
|
|
529
|
+
removeItem: (key: string) => { map.delete(key) },
|
|
530
|
+
clear: () => { map.clear() },
|
|
531
|
+
key: (index: number) => [...map.keys()][index] ?? null,
|
|
532
|
+
get length() { return map.size },
|
|
533
|
+
} as Storage
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** A user/message whose source.kind is 'plugin' (injected system message). */
|
|
537
|
+
const systemEvents = (): Array<{ event: WireEvent }> => [
|
|
538
|
+
makeEntry('user/message', {
|
|
539
|
+
id: 'u-plugin',
|
|
540
|
+
role: 'user',
|
|
541
|
+
content: [{ type: 'text', text: '系统注入消息' }],
|
|
542
|
+
source: { kind: 'plugin', name: 'react-extension' },
|
|
543
|
+
}, 0),
|
|
544
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
const toolEvents = (): Array<{ event: WireEvent }> => [
|
|
548
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
|
|
549
|
+
makeEntry('assistant/message', {
|
|
550
|
+
turn: 0,
|
|
551
|
+
step: 0,
|
|
552
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
|
|
553
|
+
}, 1),
|
|
554
|
+
makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
beforeEach(() => {
|
|
558
|
+
vi.stubGlobal('localStorage', makeStorage())
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
afterEach(() => {
|
|
562
|
+
vi.unstubAllGlobals()
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('hides injected user messages by default and reveals them via the display sheet', async () => {
|
|
566
|
+
loadHistoryMock.mockResolvedValue(historyPage(systemEvents()))
|
|
567
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
568
|
+
|
|
569
|
+
// The plugin-injected message is hidden by default; the real user message shows.
|
|
570
|
+
expect(await screen.findByText('普通消息')).toBeTruthy()
|
|
571
|
+
expect(screen.queryByText('系统注入消息')).toBeNull()
|
|
572
|
+
|
|
573
|
+
// Open the display sheet and flip the system-message switch on.
|
|
574
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }))
|
|
575
|
+
const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' })
|
|
576
|
+
expect(systemSwitch.getAttribute('aria-checked')).toBe('false')
|
|
577
|
+
fireEvent.click(systemSwitch)
|
|
578
|
+
expect(await screen.findByText('系统注入消息')).toBeTruthy()
|
|
579
|
+
|
|
580
|
+
// Flip it back off: the injected row disappears again.
|
|
581
|
+
const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' })
|
|
582
|
+
expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true')
|
|
583
|
+
fireEvent.click(systemSwitchAfter)
|
|
584
|
+
expect(screen.queryByText('系统注入消息')).toBeNull()
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('hides the tool disclosure when the tool-call toggle is off', async () => {
|
|
588
|
+
loadHistoryMock.mockResolvedValue(historyPage(toolEvents()))
|
|
589
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
590
|
+
|
|
591
|
+
// Tool disclosure visible by default.
|
|
592
|
+
expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy()
|
|
593
|
+
|
|
594
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }))
|
|
595
|
+
const toolSwitch = await screen.findByRole('switch', { name: '工具调用' })
|
|
596
|
+
expect(toolSwitch.getAttribute('aria-checked')).toBe('true')
|
|
597
|
+
fireEvent.click(toolSwitch)
|
|
598
|
+
|
|
599
|
+
// The disclosure is gone while reasoning/text remain.
|
|
600
|
+
expect(screen.queryByRole('button', { name: /工具/ })).toBeNull()
|
|
601
|
+
expect(screen.getByText('完成')).toBeTruthy()
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('renders the context usage chip from request/context plus assistant usage', async () => {
|
|
605
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
606
|
+
makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
607
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
|
|
608
|
+
makeEntry('assistant/message', {
|
|
609
|
+
turn: 0,
|
|
610
|
+
step: 0,
|
|
611
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
612
|
+
usage: { inputTokens: 30_000, outputTokens: 1_000 },
|
|
613
|
+
}, 2),
|
|
614
|
+
]))
|
|
615
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
616
|
+
expect(await screen.findByText('上下文 30%')).toBeTruthy()
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('adds the warn class when context usage is at or above 80%', async () => {
|
|
620
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
621
|
+
makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
622
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
|
|
623
|
+
makeEntry('assistant/message', {
|
|
624
|
+
turn: 0,
|
|
625
|
+
step: 0,
|
|
626
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
627
|
+
usage: { inputTokens: 80_000, outputTokens: 0 },
|
|
628
|
+
}, 2),
|
|
629
|
+
]))
|
|
630
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
631
|
+
const chip = await screen.findByText('上下文 80%')
|
|
632
|
+
expect(chip.className).toContain('chat-context-warn')
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('renders no context chip when there is no usage/context data', async () => {
|
|
636
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
637
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
638
|
+
await screen.findByText('已完成修改')
|
|
639
|
+
expect(screen.queryByText(/上下文/)).toBeNull()
|
|
640
|
+
})
|
|
641
|
+
})
|