@veltra/ai 2.1.3 → 2.1.5

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 (38) 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/ask-question.js.map +1 -1
  11. package/dist/components/ai-chat/chat-input.js +75 -20
  12. package/dist/components/ai-chat/chat-input.js.map +1 -1
  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/model-picker.js.map +1 -1
  17. package/dist/components/ai-chat/queue-list.js.map +1 -1
  18. package/dist/components/ai-chat/side-panel.js.map +1 -1
  19. package/dist/components/ai-chat/style.d.ts +1 -0
  20. package/dist/components/ai-chat/style.js +1 -0
  21. package/dist/components/ai-chat/style2.css +28 -2
  22. package/dist/components/ai-chat/tool-call.js.map +1 -1
  23. package/dist/components/ai-chat/turn-process.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/types/ai-chat.d.ts +11 -2
  26. package/package.json +1 -1
  27. package/src/chat/__test__/use-chat.test.ts +67 -0
  28. package/src/chat/transports/__test__/openai.test.ts +140 -0
  29. package/src/chat/transports/openai.ts +73 -7
  30. package/src/chat/types.ts +31 -0
  31. package/src/chat/use-chat.ts +39 -1
  32. package/src/components/ai-chat/__test__/ai-chat.test.ts +121 -1
  33. package/src/components/ai-chat/ai-chat.vue +13 -0
  34. package/src/components/ai-chat/chat-input.vue +64 -3
  35. package/src/components/ai-chat/message-list.vue +22 -0
  36. package/src/components/ai-chat/style.scss +31 -2
  37. package/src/components/ai-chat/style.ts +1 -0
  38. package/src/types/ai-chat.ts +11 -1
@@ -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,
@@ -30,6 +30,26 @@
30
30
  <Attach />
31
31
  </UIcon>
32
32
  </UFilePicker>
33
+
34
+ <span :class="[cls.e('input-clear-wrap'), bem.is('disabled', !clearable)]">
35
+ <UPopConfirm
36
+ title="清空当前对话?进行中的生成将被中止。"
37
+ confirm-text="清空"
38
+ direction="top"
39
+ alignment="start"
40
+ @confirm="emit('clear')"
41
+ >
42
+ <template #reference>
43
+ <UIcon :class="cls.e('input-clear')" title="清除会话">
44
+ <Clear />
45
+ </UIcon>
46
+ </template>
47
+ </UPopConfirm>
48
+ </span>
49
+
50
+ <span v-if="tokenUsage" :class="cls.e('input-usage')" :title="usageText">
51
+ {{ usageText }}
52
+ </span>
33
53
  </div>
34
54
 
35
55
  <!-- 右簇:模型/推理选择 → 发送或停止(互斥) -->
@@ -69,12 +89,12 @@
69
89
  </template>
70
90
 
71
91
  <script lang="ts" setup>
72
- import { UButton, UFilePicker, UIcon } from '@veltra/desktop'
73
- import { Attach, Close, Up } from '@veltra/icons/normal'
92
+ import { UButton, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
93
+ import { Attach, Clear, Close, Up } from '@veltra/icons/normal'
74
94
  import { bem } from '@veltra/utils'
75
95
  import { computed, inject, nextTick, ref, shallowRef } from 'vue'
76
96
 
77
- import type { ChatAttachment } from '../../chat/types'
97
+ import type { ChatAttachment, ChatTokenUsage } from '../../chat/types'
78
98
  import type { ChatModelOption } from '../../providers'
79
99
  import { AiChatDIKey } from './di'
80
100
  import ModelPicker from './model-picker.vue'
@@ -92,6 +112,12 @@ const props = defineProps<{
92
112
  accept?: string
93
113
  /** 单个附件最大字节数 */
94
114
  maxAttachmentSize?: number
115
+ /** 是否可清除(有消息 / 队列 / 生成中) */
116
+ clearable?: boolean
117
+ /** 会话累计 token;无 usage 时为 null,不展示 */
118
+ tokenUsage?: ChatTokenUsage | null
119
+ /** 是否展示缓存命中 / 未命中(默认只显示总 token) */
120
+ tokenUsageDetail?: boolean
95
121
  }>()
96
122
 
97
123
  const model = defineModel<string>('model')
@@ -100,6 +126,7 @@ const reasoningLevel = defineModel<string>('reasoningLevel')
100
126
  const emit = defineEmits<{
101
127
  (e: 'send', content: string, attachments: ChatAttachment[]): void
102
128
  (e: 'abort'): void
129
+ (e: 'clear'): void
103
130
  }>()
104
131
 
105
132
  const di = inject(AiChatDIKey)
@@ -114,6 +141,40 @@ const hasContent = computed(() => !!text.value.trim() || attachments.value.lengt
114
141
  /** 生成中且输入为空时显示停止;有内容则显示发送(入队) */
115
142
  const showStop = computed(() => props.running && !hasContent.value)
116
143
 
144
+ /**
145
+ * 用量数字:不到 1000 原样;≥1000 用 K;≥100 万用 M。
146
+ * 最多 1 位小数,整数不写 `.0`(1000 → 1K,1500 → 1.5K)。
147
+ */
148
+ const formatTokenCount = (n: number) => {
149
+ const scaled = (value: number, unit: 'K' | 'M') => {
150
+ const rounded = Math.round(value * 10) / 10
151
+ return `${Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)}${unit}`
152
+ }
153
+ if (n < 1000) return String(n)
154
+ if (n < 1_000_000) {
155
+ const k = Math.round((n / 1000) * 10) / 10
156
+ // 四舍五入后会变成 1000K 时改用 M
157
+ return k >= 1000 ? scaled(n / 1_000_000, 'M') : scaled(n / 1000, 'K')
158
+ }
159
+ return scaled(n / 1_000_000, 'M')
160
+ }
161
+
162
+ /** 会话累计:总 token;明细再拼有数据的缓存项,缺字段不写 0 */
163
+ const usageText = computed(() => {
164
+ const usage = props.tokenUsage
165
+ if (!usage) return ''
166
+ const parts = [`总 token ${formatTokenCount(usage.totalTokens)}`]
167
+ if (props.tokenUsageDetail) {
168
+ if (usage.cacheHitTokens != null) {
169
+ parts.push(`缓存命中 ${formatTokenCount(usage.cacheHitTokens)}`)
170
+ }
171
+ if (usage.cacheMissTokens != null) {
172
+ parts.push(`缓存未命中 ${formatTokenCount(usage.cacheMissTokens)}`)
173
+ }
174
+ }
175
+ return parts.join(' · ')
176
+ })
177
+
117
178
  /** 生成中提示用户消息将进入队列 */
118
179
  const placeholderText = computed(() => {
119
180
  if (props.running) return '会话进行中,发送的消息将进入待发送队列'
@@ -317,6 +317,15 @@ watch(
317
317
  }
318
318
  if (!prev) return
319
319
 
320
+ // 清除会话后列表已空:跳过结束停留,立刻回到欢迎区
321
+ if (visibleMessages.value.length === 0) {
322
+ finishing.value = false
323
+ workingLeaving.value = false
324
+ idleLeaving.value = false
325
+ idleEnter.value = false
326
+ return
327
+ }
328
+
320
329
  finishing.value = true
321
330
  idleLeaving.value = false
322
331
  const last = visibleMessages.value[visibleMessages.value.length - 1]
@@ -341,6 +350,19 @@ watch(
341
350
  }
342
351
  )
343
352
 
353
+ // 消息被清空(clear):立刻收掉工作球,欢迎区恢复
354
+ watch(
355
+ () => visibleMessages.value.length,
356
+ (len) => {
357
+ if (len > 0) return
358
+ clearJumpTimers()
359
+ finishing.value = false
360
+ workingLeaving.value = false
361
+ idleLeaving.value = false
362
+ idleEnter.value = false
363
+ }
364
+ )
365
+
344
366
  // 空闲欢迎区展示期间自动轮换;跳到工作位后停止
345
367
  watch(
346
368
  () => [welcomeItems.value.length, showIdleWelcome.value] as const,
@@ -889,7 +889,6 @@ $root-name: ai-chat;
889
889
  font-size: fn.use-var(font-size-main, default);
890
890
  font-family: inherit;
891
891
  line-height: 1.6;
892
- padding: fn.use-var(gap, small);
893
892
  box-sizing: border-box;
894
893
 
895
894
  &::placeholder {
@@ -906,7 +905,8 @@ $root-name: ai-chat;
906
905
  @include m.e(input-toolbar-left) {
907
906
  @include m.flex(flex, flex-start, center);
908
907
  gap: fn.use-var(gap, small);
909
- flex-shrink: 0;
908
+ flex: 1;
909
+ min-width: 0;
910
910
  }
911
911
 
912
912
  @include m.e(input-toolbar-right) {
@@ -960,6 +960,35 @@ $root-name: ai-chat;
960
960
  }
961
961
  }
962
962
 
963
+ @include m.e(input-clear-wrap) {
964
+ flex-shrink: 0;
965
+ display: inline-flex;
966
+ line-height: 0;
967
+
968
+ @include m.is(disabled) {
969
+ opacity: 0.4;
970
+ pointer-events: none;
971
+ }
972
+ }
973
+
974
+ @include m.e(input-clear) {
975
+ color: fn.use-var(text-color, assist);
976
+ cursor: pointer;
977
+ font-size: 18px;
978
+
979
+ &:hover {
980
+ color: fn.use-var(color, danger);
981
+ }
982
+ }
983
+
984
+ @include m.e(input-usage) {
985
+ min-width: 0;
986
+ @include m.ellipsis;
987
+ font-size: fn.use-var(font-size-main, small);
988
+ color: fn.use-var(text-color, assist);
989
+ line-height: 1.4;
990
+ }
991
+
963
992
  @include m.e(input-stop) {
964
993
  display: block;
965
994
  width: 10px;
@@ -5,6 +5,7 @@ import '@veltra/desktop/components/icon/style'
5
5
  import '@veltra/desktop/components/input/style'
6
6
  import '@veltra/desktop/components/layout/style'
7
7
  import '@veltra/desktop/components/message/style'
8
+ import '@veltra/desktop/components/pop-confirm/style'
8
9
  import '@veltra/desktop/components/scroll/style'
9
10
  import '@veltra/desktop/components/select/style'
10
11
  import '@veltra/styles/animations/shine.scss'
@@ -5,6 +5,7 @@ import type {
5
5
  ChatAttachment,
6
6
  ChatMessage,
7
7
  ChatQueuedMessage,
8
+ ChatTokenUsage,
8
9
  ChatTool,
9
10
  ChatToolCall,
10
11
  ChatTransport
@@ -44,6 +45,11 @@ export interface AiChatProps {
44
45
  maxAttachmentSize?: number
45
46
  /** 透传给内部 MarkdownRender 的属性 */
46
47
  rendererProps?: Record<string, unknown>
48
+ /**
49
+ * 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
50
+ * 默认 false:仅在拿到 usage 时显示会话累计「总 token」。接口未返回 usage 时不展示。
51
+ */
52
+ tokenUsageDetail?: boolean
47
53
  }
48
54
 
49
55
  export interface AiChatEmits {
@@ -70,8 +76,12 @@ export interface _AiChatExposed {
70
76
  abort: () => void
71
77
  /** 重新生成最后一条 assistant 回复 */
72
78
  regenerate: () => void
73
- /** 清空消息与待发送队列 */
79
+ /** 清空消息、待发送队列与 token 统计(生成中会先中止) */
74
80
  clear: () => void
81
+ /** 当前会话累计 token(从未收到 usage 时为 null) */
82
+ tokenUsage: Ref<ChatTokenUsage | null>
83
+ /** 最近一轮用户对话的 token(含工具多轮请求;该轮无 usage 时为 null) */
84
+ lastTurnUsage: Ref<ChatTokenUsage | null>
75
85
  /** 待发送消息队列(会话进行中提交的消息按序排队,收尾后 FIFO 自动接续) */
76
86
  queue: Ref<ChatQueuedMessage[]>
77
87
  /** 立即执行队列中的某条:中断当前会话并插队为下一条 */