@veltra/ai 2.1.2 → 2.1.4

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.
Files changed (34) hide show
  1. package/dist/chat/index.d.ts +2 -2
  2. package/dist/chat/transports/openai.js +52 -6
  3. package/dist/chat/transports/openai.js.map +1 -1
  4. package/dist/chat/types.d.ts +31 -1
  5. package/dist/chat/use-chat.d.ts +31 -1
  6. package/dist/chat/use-chat.js +33 -1
  7. package/dist/chat/use-chat.js.map +1 -1
  8. package/dist/components/ai-chat/ai-chat.js +17 -4
  9. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  10. package/dist/components/ai-chat/chat-input.js +75 -20
  11. package/dist/components/ai-chat/chat-input.js.map +1 -1
  12. package/dist/components/ai-chat/message-item.js +1 -6
  13. package/dist/components/ai-chat/message-item.js.map +1 -1
  14. package/dist/components/ai-chat/message-list.js +15 -0
  15. package/dist/components/ai-chat/message-list.js.map +1 -1
  16. package/dist/components/ai-chat/style.d.ts +1 -0
  17. package/dist/components/ai-chat/style.js +1 -0
  18. package/dist/components/ai-chat/style2.css +38 -11
  19. package/dist/index.d.ts +2 -2
  20. package/dist/types/ai-chat.d.ts +11 -2
  21. package/package.json +5 -5
  22. package/src/chat/__test__/use-chat.test.ts +67 -0
  23. package/src/chat/transports/__test__/openai.test.ts +140 -0
  24. package/src/chat/transports/openai.ts +73 -7
  25. package/src/chat/types.ts +31 -0
  26. package/src/chat/use-chat.ts +39 -1
  27. package/src/components/ai-chat/__test__/ai-chat.test.ts +121 -1
  28. package/src/components/ai-chat/ai-chat.vue +13 -0
  29. package/src/components/ai-chat/chat-input.vue +64 -3
  30. package/src/components/ai-chat/message-item.vue +0 -1
  31. package/src/components/ai-chat/message-list.vue +22 -0
  32. package/src/components/ai-chat/style.scss +50 -15
  33. package/src/components/ai-chat/style.ts +1 -0
  34. package/src/types/ai-chat.ts +11 -1
@@ -375,6 +375,8 @@ describe('useChat', () => {
375
375
 
376
376
  expect(chat.messages.value).toHaveLength(0)
377
377
  expect(chat.queue.value).toHaveLength(0)
378
+ expect(chat.tokenUsage.value).toBeNull()
379
+ expect(chat.lastTurnUsage.value).toBeNull()
378
380
  })
379
381
 
380
382
  it('请求携带 model 与 reasoningLevel', async () => {
@@ -567,4 +569,69 @@ describe('useChat', () => {
567
569
  // 最终同步给父级的快照也包含 assistant 消息
568
570
  expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
569
571
  })
572
+
573
+ it('累计 onUsage,无 usage 时保持 null;clear 一并重置', async () => {
574
+ const emit = createEmit()
575
+ const requests: ChatTransportRequest[] = []
576
+ const transport: ChatTransport = (req, handlers) => {
577
+ requests.push(req)
578
+ if (requests.length === 1) {
579
+ handlers.onToolCall?.({ id: 'call-1', name: 'add', arguments: '{"a":1,"b":2}' })
580
+ handlers.onUsage?.({
581
+ promptTokens: 10,
582
+ completionTokens: 4,
583
+ totalTokens: 14,
584
+ cacheHitTokens: 6,
585
+ cacheMissTokens: 4
586
+ })
587
+ } else {
588
+ handlers.onTextDelta('3')
589
+ handlers.onUsage?.({
590
+ promptTokens: 20,
591
+ completionTokens: 2,
592
+ totalTokens: 22,
593
+ cacheHitTokens: 12,
594
+ cacheMissTokens: 8
595
+ })
596
+ }
597
+ }
598
+ const tools: ChatTool[] = [
599
+ { name: 'add', description: '加法', parameters: {}, execute: () => 3 }
600
+ ]
601
+ const chat = useChat({ props: { transport, tools }, emit })
602
+
603
+ expect(chat.tokenUsage.value).toBeNull()
604
+ chat.send('算一下')
605
+ await waitFinish(emit)
606
+
607
+ expect(chat.lastTurnUsage.value).toEqual({
608
+ promptTokens: 30,
609
+ completionTokens: 6,
610
+ totalTokens: 36,
611
+ cacheHitTokens: 18,
612
+ cacheMissTokens: 12
613
+ })
614
+ expect(chat.tokenUsage.value).toEqual(chat.lastTurnUsage.value)
615
+
616
+ chat.send('再算')
617
+ await vi.waitFor(() => {
618
+ expect(emit.mock.calls.filter((call) => call[0] === 'finish')).toHaveLength(2)
619
+ })
620
+ expect(chat.lastTurnUsage.value?.totalTokens).toBe(22)
621
+ expect(chat.tokenUsage.value?.totalTokens).toBe(58)
622
+ expect(chat.tokenUsage.value?.cacheHitTokens).toBe(30)
623
+
624
+ chat.clear()
625
+ expect(chat.tokenUsage.value).toBeNull()
626
+ expect(chat.lastTurnUsage.value).toBeNull()
627
+ })
628
+
629
+ it('transport 不回调 onUsage 时不编造数字', async () => {
630
+ const emit = createEmit()
631
+ const chat = useChat({ props: { transport: textTransport('ok') }, emit })
632
+ chat.send('hi')
633
+ await waitFinish(emit)
634
+ expect(chat.tokenUsage.value).toBeNull()
635
+ expect(chat.lastTurnUsage.value).toBeNull()
636
+ })
570
637
  })
@@ -7,6 +7,7 @@ const baseHandlers = (): ChatTransportHandlers => ({
7
7
  onTextDelta: vi.fn(),
8
8
  onReasoningDelta: vi.fn(),
9
9
  onToolCall: vi.fn(),
10
+ onUsage: vi.fn(),
10
11
  onError: vi.fn()
11
12
  })
12
13
 
@@ -314,4 +315,143 @@ data: [DONE]
314
315
  arguments: '{"y":2}'
315
316
  })
316
317
  })
318
+
319
+ it('请求体带 stream_options.include_usage', async () => {
320
+ const fetchMock = vi.fn(async () => {
321
+ return new Response('data: [DONE]\n\n', {
322
+ status: 200,
323
+ headers: { 'Content-Type': 'text/event-stream' }
324
+ })
325
+ })
326
+ vi.stubGlobal('fetch', fetchMock)
327
+
328
+ const transport = createOpenAITransport({
329
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
330
+ })
331
+ await transport(
332
+ {
333
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
334
+ signal: new AbortController().signal
335
+ },
336
+ baseHandlers()
337
+ )
338
+
339
+ expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
340
+ stream: true,
341
+ stream_options: { include_usage: true }
342
+ })
343
+ })
344
+
345
+ it('解析末包 usage,含缓存命中 / 未命中,无 usage 不回调', async () => {
346
+ const sse = String.raw`data: {"choices":[{"delta":{"content":"hi"}}]}
347
+
348
+ data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":8},"prompt_cache_hit_tokens":8,"prompt_cache_miss_tokens":4}}
349
+
350
+ data: [DONE]
351
+ `
352
+ vi.stubGlobal(
353
+ 'fetch',
354
+ vi.fn(async () => new Response(sse, { status: 200 }))
355
+ )
356
+
357
+ const transport = createOpenAITransport({
358
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
359
+ })
360
+ const handlers = baseHandlers()
361
+ await transport(
362
+ {
363
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
364
+ signal: new AbortController().signal
365
+ },
366
+ handlers
367
+ )
368
+
369
+ expect(handlers.onTextDelta).toHaveBeenCalledWith('hi')
370
+ expect(handlers.onUsage).toHaveBeenCalledTimes(1)
371
+ expect(handlers.onUsage).toHaveBeenCalledWith({
372
+ promptTokens: 12,
373
+ completionTokens: 3,
374
+ totalTokens: 15,
375
+ cacheHitTokens: 8,
376
+ cacheMissTokens: 4
377
+ })
378
+ })
379
+
380
+ it('仅有 cached_tokens 时由 prompt − 命中得到未命中;缺 usage 不编造', async () => {
381
+ const withCache = String.raw`data: {"choices":[{"delta":{"content":"a"}}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11,"prompt_tokens_details":{"cached_tokens":7}}}
382
+
383
+ data: [DONE]
384
+ `
385
+ const noUsage = String.raw`data: {"choices":[{"delta":{"content":"b"}}]}
386
+
387
+ data: [DONE]
388
+ `
389
+ const fetchMock = vi
390
+ .fn()
391
+ .mockResolvedValueOnce(new Response(withCache, { status: 200 }))
392
+ .mockResolvedValueOnce(new Response(noUsage, { status: 200 }))
393
+ vi.stubGlobal('fetch', fetchMock)
394
+
395
+ const transport = createOpenAITransport({
396
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
397
+ })
398
+
399
+ const withCacheHandlers = baseHandlers()
400
+ await transport(
401
+ {
402
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
403
+ signal: new AbortController().signal
404
+ },
405
+ withCacheHandlers
406
+ )
407
+ expect(withCacheHandlers.onUsage).toHaveBeenCalledWith({
408
+ promptTokens: 10,
409
+ completionTokens: 1,
410
+ totalTokens: 11,
411
+ cacheHitTokens: 7,
412
+ cacheMissTokens: 3
413
+ })
414
+
415
+ const noUsageHandlers = baseHandlers()
416
+ await transport(
417
+ {
418
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
419
+ signal: new AbortController().signal
420
+ },
421
+ noUsageHandlers
422
+ )
423
+ expect(noUsageHandlers.onUsage).not.toHaveBeenCalled()
424
+ })
425
+
426
+ it('解析 Anthropic 风格 cache_read / cache_creation', async () => {
427
+ const sse = String.raw`data: {"choices":[{"delta":{"content":"ok"}}],"usage":{"prompt_tokens":20,"completion_tokens":2,"total_tokens":22,"cache_read_input_tokens":15,"cache_creation_input_tokens":3}}
428
+
429
+ data: [DONE]
430
+ `
431
+ vi.stubGlobal(
432
+ 'fetch',
433
+ vi.fn(async () => new Response(sse, { status: 200 }))
434
+ )
435
+
436
+ const transport = createOpenAITransport({
437
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
438
+ })
439
+ const handlers = baseHandlers()
440
+ await transport(
441
+ {
442
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
443
+ signal: new AbortController().signal
444
+ },
445
+ handlers
446
+ )
447
+
448
+ expect(handlers.onUsage).toHaveBeenCalledWith({
449
+ promptTokens: 20,
450
+ completionTokens: 2,
451
+ totalTokens: 22,
452
+ cacheHitTokens: 15,
453
+ cacheMissTokens: 5,
454
+ cacheCreationTokens: 3
455
+ })
456
+ })
317
457
  })
@@ -1,6 +1,7 @@
1
1
  import type { ChatModelOption, ChatProvider } from '../../providers'
2
2
  import type {
3
3
  ChatMessage,
4
+ ChatTokenUsage,
4
5
  ChatTool,
5
6
  ChatTransport,
6
7
  ChatTransportHandlers,
@@ -82,6 +83,58 @@ interface AccumulatedToolCall {
82
83
  arguments: string
83
84
  }
84
85
 
86
+ function isRecord(value: unknown): value is Record<string, unknown> {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
88
+ }
89
+
90
+ /** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
91
+ function readCount(value: unknown): number | undefined {
92
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
93
+ return Math.floor(value)
94
+ }
95
+
96
+ /**
97
+ * 从 OpenAI 兼容 usage 对象解析。
98
+ * 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
99
+ */
100
+ function parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {
101
+ if (!isRecord(raw)) return null
102
+
103
+ const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
104
+ const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
105
+ if (promptTokens == null && completionTokens == null) return null
106
+
107
+ const prompt = promptTokens ?? 0
108
+ const completion = completionTokens ?? 0
109
+ const usage: ChatTokenUsage = {
110
+ promptTokens: prompt,
111
+ completionTokens: completion,
112
+ totalTokens: readCount(raw.total_tokens) ?? prompt + completion
113
+ }
114
+
115
+ const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
116
+ const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
117
+ const cacheHitTokens =
118
+ readCount(details?.cached_tokens) ??
119
+ readCount(inputDetails?.cached_tokens) ??
120
+ readCount(raw.prompt_cache_hit_tokens) ??
121
+ readCount(raw.cache_read_input_tokens) ??
122
+ readCount(raw.cached_tokens)
123
+ if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
124
+
125
+ const cacheMissTokens =
126
+ readCount(raw.prompt_cache_miss_tokens) ??
127
+ (cacheHitTokens != null && promptTokens != null
128
+ ? Math.max(0, promptTokens - cacheHitTokens)
129
+ : undefined)
130
+ if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
131
+
132
+ const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
133
+ if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
134
+
135
+ return usage
136
+ }
137
+
85
138
  /**
86
139
  * 逐行解析 SSE 流。
87
140
  * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
@@ -112,15 +165,21 @@ async function parseSSE(
112
165
  const handleData = (data: string) => {
113
166
  if (data === '[DONE]') return
114
167
 
115
- let chunk: any
168
+ let chunk: unknown
116
169
  try {
117
170
  chunk = JSON.parse(data)
118
171
  } catch {
119
172
  return
120
173
  }
174
+ if (!isRecord(chunk)) return
175
+
176
+ const usage = parseOpenAIUsage(chunk.usage)
177
+ if (usage) handlers.onUsage?.(usage)
121
178
 
122
- const delta = chunk.choices?.[0]?.delta
123
- if (!delta) return
179
+ const choices = chunk.choices
180
+ const first = Array.isArray(choices) ? choices[0] : undefined
181
+ const delta = isRecord(first) ? first.delta : undefined
182
+ if (!isRecord(delta)) return
124
183
 
125
184
  if (typeof delta.content === 'string' && delta.content) {
126
185
  handlers.onTextDelta(delta.content)
@@ -134,7 +193,8 @@ async function parseSSE(
134
193
 
135
194
  if (Array.isArray(delta.tool_calls)) {
136
195
  for (const tc of delta.tool_calls) {
137
- const index: number = tc.index ?? 0
196
+ if (!isRecord(tc)) continue
197
+ const index = typeof tc.index === 'number' ? tc.index : 0
138
198
  // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
139
199
  if (index > lastIndex && accumulated.size > 0) flushToolCalls()
140
200
  lastIndex = Math.max(lastIndex, index)
@@ -144,9 +204,10 @@ async function parseSSE(
144
204
  call = { id: '', name: '', arguments: '' }
145
205
  accumulated.set(index, call)
146
206
  }
147
- if (tc.id) call.id = tc.id
148
- if (tc.function?.name) call.name += tc.function.name
149
- if (tc.function?.arguments) call.arguments += tc.function.arguments
207
+ if (typeof tc.id === 'string') call.id = tc.id
208
+ const fn = isRecord(tc.function) ? tc.function : undefined
209
+ if (typeof fn?.name === 'string') call.name += fn.name
210
+ if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
150
211
  }
151
212
  }
152
213
  }
@@ -240,10 +301,15 @@ export function createOpenAITransport(options: OpenAITransportOptions): OpenAITr
240
301
  return
241
302
  }
242
303
 
304
+ const extraStreamOptions = isRecord(globalBody?.stream_options)
305
+ ? globalBody.stream_options
306
+ : undefined
243
307
  const requestBody: Record<string, unknown> = {
244
308
  ...globalBody,
245
309
  model: modelId,
246
310
  stream: true,
311
+ // 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
312
+ stream_options: { ...extraStreamOptions, include_usage: true },
247
313
  messages: toOpenAIMessages(messages, systemPrompt),
248
314
  ...(tools?.length ? { tools: toOpenAITools(tools) } : {})
249
315
  }
package/src/chat/types.ts CHANGED
@@ -151,6 +151,32 @@ export interface ChatTransportRequest {
151
151
  signal: AbortSignal
152
152
  }
153
153
 
154
+ /**
155
+ * 一次模型请求的 token 用量。
156
+ * 仅包含接口实际返回的字段;缓存类数字缺省为 undefined,不补 0。
157
+ */
158
+ export interface ChatTokenUsage {
159
+ /** 输入 token */
160
+ promptTokens: number
161
+ /** 输出 token */
162
+ completionTokens: number
163
+ /** 合计 token */
164
+ totalTokens: number
165
+ /**
166
+ * 缓存命中。
167
+ * 来源:`prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
168
+ * `cache_read_input_tokens` / `cached_tokens`。
169
+ */
170
+ cacheHitTokens?: number
171
+ /**
172
+ * 缓存未命中。
173
+ * 来源:`prompt_cache_miss_tokens`;若无该字段但有命中与 prompt,则为 prompt − 命中。
174
+ */
175
+ cacheMissTokens?: number
176
+ /** 写入缓存(Anthropic 风格 `cache_creation_input_tokens`),有才有 */
177
+ cacheCreationTokens?: number
178
+ }
179
+
154
180
  /** transport 流式事件回调 */
155
181
  export interface ChatTransportHandlers {
156
182
  /** 文本增量 */
@@ -162,6 +188,11 @@ export interface ChatTransportHandlers {
162
188
  * 流式参数片段由 transport 内部累积,参数完整后才通过该回调抛出。
163
189
  */
164
190
  onToolCall?(call: { id: string; name: string; arguments: string }): void
191
+ /**
192
+ * 本次请求的 token 用量(流式末包或非流式 usage)。
193
+ * 接口未返回 usage 时不要调用,不要填 0 充数。
194
+ */
195
+ onUsage?(usage: ChatTokenUsage): void
165
196
  /** 请求错误 */
166
197
  onError?(error: Error): void
167
198
  }
@@ -7,6 +7,7 @@ import type {
7
7
  ChatAttachment,
8
8
  ChatMessage,
9
9
  ChatQueuedMessage,
10
+ ChatTokenUsage,
10
11
  ChatTool,
11
12
  ChatToolCall
12
13
  } from './types'
@@ -38,6 +39,27 @@ function resolveTools(userTools?: ChatTool[]): ChatTool[] {
38
39
  return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
39
40
  }
40
41
 
42
+ function mergeOptionalCount(a?: number, b?: number): number | undefined {
43
+ if (a == null && b == null) return undefined
44
+ return (a ?? 0) + (b ?? 0)
45
+ }
46
+
47
+ /** 累加两次 usage;缓存字段只在至少一侧有值时保留 */
48
+ function addTokenUsage(a: ChatTokenUsage, b: ChatTokenUsage): ChatTokenUsage {
49
+ const usage: ChatTokenUsage = {
50
+ promptTokens: a.promptTokens + b.promptTokens,
51
+ completionTokens: a.completionTokens + b.completionTokens,
52
+ totalTokens: a.totalTokens + b.totalTokens
53
+ }
54
+ const cacheHitTokens = mergeOptionalCount(a.cacheHitTokens, b.cacheHitTokens)
55
+ if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
56
+ const cacheMissTokens = mergeOptionalCount(a.cacheMissTokens, b.cacheMissTokens)
57
+ if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
58
+ const cacheCreationTokens = mergeOptionalCount(a.cacheCreationTokens, b.cacheCreationTokens)
59
+ if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
60
+ return usage
61
+ }
62
+
41
63
  /**
42
64
  * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
43
65
  * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
@@ -66,6 +88,11 @@ export function useChat(options: UseChatOptions) {
66
88
  /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */
67
89
  const queue = ref<ChatQueuedMessage[]>([])
68
90
 
91
+ /** 当前会话累计 token;从未收到 usage 时为 null */
92
+ const tokenUsage = ref<ChatTokenUsage | null>(null)
93
+ /** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
94
+ const lastTurnUsage = ref<ChatTokenUsage | null>(null)
95
+
69
96
  let abortController: AbortController | null = null
70
97
  /** needsConfirm 工具的挂起确认器,key 为 toolCallId */
71
98
  const confirmResolvers = new Map<string, (approved: boolean) => void>()
@@ -238,6 +265,7 @@ export function useChat(options: UseChatOptions) {
238
265
 
239
266
  running.value = true
240
267
  finishedNaturally = false
268
+ lastTurnUsage.value = null
241
269
  const controller = new AbortController()
242
270
  abortController = controller
243
271
 
@@ -297,6 +325,12 @@ export function useChat(options: UseChatOptions) {
297
325
  assistant.toolCalls!.push(toolCall)
298
326
  emit('tool-call', toolCall)
299
327
  },
328
+ onUsage: (usage) => {
329
+ lastTurnUsage.value = lastTurnUsage.value
330
+ ? addTokenUsage(lastTurnUsage.value, usage)
331
+ : usage
332
+ tokenUsage.value = tokenUsage.value ? addTokenUsage(tokenUsage.value, usage) : usage
333
+ },
300
334
  onError: (error) => {
301
335
  requestError = error
302
336
  }
@@ -388,11 +422,13 @@ export function useChat(options: UseChatOptions) {
388
422
  void runConversation()
389
423
  }
390
424
 
391
- /** 清空消息与待发送队列,生成中则先中断 */
425
+ /** 清空消息、待发送队列与 token 统计,生成中则先中断 */
392
426
  const clear = () => {
393
427
  abort()
394
428
  queue.value = []
395
429
  messages.value = []
430
+ tokenUsage.value = null
431
+ lastTurnUsage.value = null
396
432
  snapshot()
397
433
  }
398
434
 
@@ -402,6 +438,8 @@ export function useChat(options: UseChatOptions) {
402
438
  reasoningLevel,
403
439
  running,
404
440
  queue,
441
+ tokenUsage,
442
+ lastTurnUsage,
405
443
  send,
406
444
  abort,
407
445
  regenerate,
@@ -23,6 +23,7 @@ function mountAiChat(options: {
23
23
  models?: ChatModelOption[]
24
24
  model?: string
25
25
  reasoningLevel?: string
26
+ tokenUsageDetail?: boolean
26
27
  slots?: Record<string, (scope: any) => any>
27
28
  }) {
28
29
  const host = document.createElement('div')
@@ -41,7 +42,8 @@ function mountAiChat(options: {
41
42
  welcome: options.welcome,
42
43
  models: options.models,
43
44
  model: options.model,
44
- reasoningLevel: options.reasoningLevel
45
+ reasoningLevel: options.reasoningLevel,
46
+ tokenUsageDetail: options.tokenUsageDetail
45
47
  },
46
48
  options.slots
47
49
  )
@@ -1131,4 +1133,122 @@ describe('UAiChat', () => {
1131
1133
  vi.useRealTimers()
1132
1134
  }
1133
1135
  })
1136
+
1137
+ it('输入区提供清除入口;clear 清空后回到欢迎区', async () => {
1138
+ const transport: ChatTransport = (_req, handlers) => {
1139
+ handlers.onTextDelta('回复')
1140
+ }
1141
+ const { host, chat, unmount } = mountAiChat({ transport, welcome: '有什么可以帮你?' })
1142
+
1143
+ expect(host.querySelector('.u-ai-chat__input-clear')).toBeTruthy()
1144
+ expect(
1145
+ host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
1146
+ ).toBe(true)
1147
+ expect(host.querySelector('.u-ai-chat__input-usage')).toBeFalsy()
1148
+
1149
+ chat.value?.send('你好')
1150
+ await vi.waitFor(() => {
1151
+ expect(host.querySelector('.md-stub')?.textContent).toContain('回复')
1152
+ })
1153
+ expect(
1154
+ host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
1155
+ ).toBe(false)
1156
+
1157
+ chat.value?.clear()
1158
+ await vi.waitFor(() => {
1159
+ expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
1160
+ })
1161
+ expect(host.querySelector('.md-stub')).toBeFalsy()
1162
+ unmount()
1163
+ })
1164
+
1165
+ it('生成中 clear 中止请求并立即恢复欢迎区', async () => {
1166
+ const transport: ChatTransport = (req) => {
1167
+ return new Promise<void>((resolve) => {
1168
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
1169
+ })
1170
+ }
1171
+ const { host, chat, unmount } = mountAiChat({ transport, welcome: '欢迎回来' })
1172
+
1173
+ chat.value?.send('hi')
1174
+ await nextTick()
1175
+ expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
1176
+
1177
+ chat.value?.clear()
1178
+ await vi.waitFor(() => {
1179
+ expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1180
+ expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('欢迎回来')
1181
+ })
1182
+ unmount()
1183
+ })
1184
+
1185
+ it('有 usage 时默认只显示总 token,tokenUsageDetail 追加缓存命中/未命中', async () => {
1186
+ const usageTransport: ChatTransport = (_req, handlers) => {
1187
+ handlers.onTextDelta('ok')
1188
+ handlers.onUsage?.({
1189
+ promptTokens: 10,
1190
+ completionTokens: 2,
1191
+ totalTokens: 12,
1192
+ cacheHitTokens: 4,
1193
+ cacheMissTokens: 6
1194
+ })
1195
+ }
1196
+
1197
+ const compact = mountAiChat({ transport: usageTransport })
1198
+ compact.chat.value?.send('hi')
1199
+ await vi.waitFor(() => {
1200
+ expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1201
+ })
1202
+ expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain(
1203
+ '缓存命中'
1204
+ )
1205
+ compact.unmount()
1206
+
1207
+ const detail = mountAiChat({ transport: usageTransport, tokenUsageDetail: true })
1208
+ detail.chat.value?.send('hi')
1209
+ await vi.waitFor(() => {
1210
+ expect(detail.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1211
+ '总 token 12 · 缓存命中 4 · 缓存未命中 6'
1212
+ )
1213
+ })
1214
+ const text = detail.host.querySelector('.u-ai-chat__input-usage')?.textContent ?? ''
1215
+ expect(text).not.toContain('本次')
1216
+ expect(text).not.toContain('累计')
1217
+ expect(text).not.toContain('输入')
1218
+ expect(text).not.toContain('输出')
1219
+ detail.unmount()
1220
+ })
1221
+
1222
+ it('token 用量数字按 K/M 缩写,缓存字段缺失时不展示该项', async () => {
1223
+ const scaledTransport: ChatTransport = (_req, handlers) => {
1224
+ handlers.onTextDelta('ok')
1225
+ handlers.onUsage?.({
1226
+ promptTokens: 800,
1227
+ completionTokens: 700,
1228
+ totalTokens: 1500,
1229
+ cacheHitTokens: 1000,
1230
+ cacheMissTokens: 1_000_000
1231
+ })
1232
+ }
1233
+ const scaled = mountAiChat({ transport: scaledTransport, tokenUsageDetail: true })
1234
+ scaled.chat.value?.send('hi')
1235
+ await vi.waitFor(() => {
1236
+ expect(scaled.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1237
+ '总 token 1.5K · 缓存命中 1K · 缓存未命中 1M'
1238
+ )
1239
+ })
1240
+ scaled.unmount()
1241
+
1242
+ const noCacheTransport: ChatTransport = (_req, handlers) => {
1243
+ handlers.onTextDelta('ok')
1244
+ handlers.onUsage?.({ promptTokens: 10, completionTokens: 2, totalTokens: 12 })
1245
+ }
1246
+ const noCache = mountAiChat({ transport: noCacheTransport, tokenUsageDetail: true })
1247
+ noCache.chat.value?.send('hi')
1248
+ await vi.waitFor(() => {
1249
+ expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1250
+ })
1251
+ expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain('缓存')
1252
+ noCache.unmount()
1253
+ })
1134
1254
  })
@@ -40,8 +40,12 @@
40
40
  :placeholder="placeholder"
41
41
  :accept="accept"
42
42
  :max-attachment-size="maxAttachmentSize"
43
+ :clearable="clearable"
44
+ :token-usage="tokenUsage"
45
+ :token-usage-detail="tokenUsageDetail"
43
46
  @send="handleSend"
44
47
  @abort="abort"
48
+ @clear="clear"
45
49
  />
46
50
  </div>
47
51
 
@@ -102,6 +106,8 @@ const {
102
106
  reasoningLevel,
103
107
  running,
104
108
  queue,
109
+ tokenUsage,
110
+ lastTurnUsage,
105
111
  send,
106
112
  abort,
107
113
  regenerate,
@@ -115,6 +121,11 @@ const {
115
121
  /** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */
116
122
  const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))
117
123
 
124
+ /** 有对话内容、队列或生成中才允许清除,避免误触空会话 */
125
+ const clearable = computed(
126
+ () => messages.value.length > 0 || queue.value.length > 0 || running.value
127
+ )
128
+
118
129
  /** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
119
130
  const panelCalls = computed(() => {
120
131
  const calls: ChatToolCall[] = []
@@ -269,6 +280,8 @@ defineExpose<_AiChatExposed>({
269
280
  abort,
270
281
  regenerate,
271
282
  clear,
283
+ tokenUsage,
284
+ lastTurnUsage,
272
285
  queue,
273
286
  startQueued,
274
287
  removeQueued,