@veltra/ai 2.1.9 → 2.1.11

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 (49) hide show
  1. package/dist/chat/use-chat.d.ts +15 -0
  2. package/dist/chat/use-chat.js +6 -0
  3. package/dist/chat/use-chat.js.map +1 -1
  4. package/dist/components/ai-chat/ai-chat.js +3 -1
  5. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  6. package/dist/components/ai-chat/chat-input.js +155 -77
  7. package/dist/components/ai-chat/chat-input.js.map +1 -1
  8. package/dist/components/ai-chat/format-duration.js +13 -0
  9. package/dist/components/ai-chat/format-duration.js.map +1 -0
  10. package/dist/components/ai-chat/message-item.js +50 -31
  11. package/dist/components/ai-chat/message-item.js.map +1 -1
  12. package/dist/components/ai-chat/message-list.js +43 -8
  13. package/dist/components/ai-chat/message-list.js.map +1 -1
  14. package/dist/components/ai-chat/style.js +1 -0
  15. package/dist/components/ai-chat/style2.css +89 -18
  16. package/dist/components/ai-chat/tool-call.js +18 -11
  17. package/dist/components/ai-chat/tool-call.js.map +1 -1
  18. package/dist/index.d.ts +1 -3
  19. package/dist/index.js +1 -2
  20. package/dist/providers/types.d.ts +2 -0
  21. package/dist/style.js +0 -1
  22. package/dist/tools/ask-question/ask-question.js +2 -1
  23. package/dist/tools/ask-question/ask-question.js.map +1 -1
  24. package/dist/types/ai-chat.d.ts +2 -1
  25. package/dist/types/index.d.ts +1 -2
  26. package/package.json +1 -1
  27. package/src/chat/use-chat.ts +6 -0
  28. package/src/components/ai-chat/__test__/ai-chat.test.ts +131 -24
  29. package/src/components/ai-chat/ai-chat.vue +2 -0
  30. package/src/components/ai-chat/chat-input.vue +81 -13
  31. package/src/components/ai-chat/format-duration.ts +8 -0
  32. package/src/components/ai-chat/message-item.vue +24 -16
  33. package/src/components/ai-chat/message-list.vue +56 -5
  34. package/src/components/ai-chat/style.scss +101 -18
  35. package/src/components/ai-chat/style.ts +2 -0
  36. package/src/components/ai-chat/tool-call.vue +27 -9
  37. package/src/components/index.ts +0 -1
  38. package/src/providers/types.ts +2 -0
  39. package/src/style.ts +0 -1
  40. package/src/tools/ask-question/__test__/ask-question.test.ts +20 -2
  41. package/src/tools/ask-question/ask-question.ts +2 -1
  42. package/src/types/ai-chat.ts +2 -1
  43. package/src/types/index.ts +0 -1
  44. package/dist/components/ai-orb/style.d.ts +0 -1
  45. package/dist/components/ai-orb/style.js +0 -1
  46. package/dist/types/ai-orb.d.ts +0 -26
  47. package/src/components/ai-orb/index.ts +0 -1
  48. package/src/components/ai-orb/style.ts +0 -1
  49. /package/dist/components/ai-orb/{style2.css → style.css} +0 -0
@@ -207,6 +207,8 @@ describe('UAiChat', () => {
207
207
  await vi.waitFor(() => {
208
208
  expect(host.querySelector('.u-ai-chat__tool-call-confirm')).toBeTruthy()
209
209
  })
210
+ // 待确认自动展开:确认前可查看参数
211
+ expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeTruthy()
210
212
  expect(execute).not.toHaveBeenCalled()
211
213
 
212
214
  const allowBtn = [
@@ -286,21 +288,40 @@ describe('UAiChat', () => {
286
288
  ...extra
287
289
  })
288
290
 
289
- it('render 自定义渲染替换卡片 body,完成后保持展开', async () => {
291
+ it('render 自定义渲染替换卡片 body,完成后默认折叠、展开后可见', async () => {
290
292
  const tools = [weatherTool({ render: createResultView('weather-view') })]
291
293
  const { host, chat, unmount } = mountAiChat({ transport: createWeatherTransport(), tools })
292
294
 
293
295
  chat.value?.send('北京天气')
294
296
 
295
- // 最终答案输出后工具卡片收进「已完成」过程块,展开后 render 保持展开态
297
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后断言
296
298
  await expandProcess(host)
297
299
 
298
- // 完成后保持展开,默认参数/结果区被替换
300
+ // render 工具完成后默认折叠(不再是保持展开)
301
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
302
+ expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
303
+
304
+ // 手动展开:render 替换默认参数/结果区
305
+ const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
306
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
307
+ await nextTick()
308
+ await nextTick()
309
+ expect(host.querySelector('.weather-view')?.textContent).toContain('{"temperature":26}')
310
+ expect(host.querySelector('.u-ai-chat__tool-call-code')).toBeFalsy()
311
+ unmount()
312
+ })
313
+
314
+ it('terminal + render 的「UI 即答复」工具完成后保持展开', async () => {
315
+ const tools = [weatherTool({ render: createResultView('weather-view'), terminal: true })]
316
+ const { host, chat, unmount } = mountAiChat({ transport: createWeatherTransport(), tools })
317
+
318
+ chat.value?.send('北京天气')
319
+
320
+ // 终结工具成功即结束对话(无第二轮文本),答复卡片保持展开
299
321
  await vi.waitFor(() => {
300
322
  expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
301
323
  })
302
324
  expect(host.querySelector('.weather-view')?.textContent).toContain('{"temperature":26}')
303
- expect(host.querySelector('.u-ai-chat__tool-call-code')).toBeFalsy()
304
325
  unmount()
305
326
  })
306
327
 
@@ -389,6 +410,43 @@ describe('UAiChat', () => {
389
410
  unmount()
390
411
  })
391
412
 
413
+ it('思考中默认折叠,头部右侧滚动展示最新一行思考', async () => {
414
+ // 由测试手动推进流式节奏,避免断言竞态
415
+ const steps: (() => void)[] = []
416
+ const transport: ChatTransport = async (_req, handlers) => {
417
+ handlers.onReasoningDelta?.('第一行思考')
418
+ await new Promise<void>((resolve) => steps.push(resolve))
419
+ handlers.onReasoningDelta?.('\n\n第二行思考')
420
+ await new Promise<void>((resolve) => steps.push(resolve))
421
+ handlers.onTextDelta('答案')
422
+ }
423
+ const { host, chat, unmount } = mountAiChat({ transport })
424
+
425
+ chat.value?.send('hi')
426
+
427
+ // 思考中:折叠态不挂载内容 DOM,头部标题为「思考中…」且右侧展示最新一行
428
+ await vi.waitFor(() => {
429
+ expect(host.querySelector('.u-ai-chat__reasoning-line')?.textContent).toBe('第一行思考')
430
+ })
431
+ expect(host.querySelector('.u-ai-chat__reasoning-title')?.textContent).toContain('思考中')
432
+ expect(host.querySelector('.u-ai-chat__reasoning-content')).toBeFalsy()
433
+
434
+ // 流式推进后滚动到最新一行(跳过空行)
435
+ steps.shift()?.()
436
+ await vi.waitFor(() => {
437
+ expect(host.querySelector('.u-ai-chat__reasoning-line')?.textContent).toBe('第二行思考')
438
+ })
439
+
440
+ // 正文开始输出后:标题回到「思考过程」,不再展示行
441
+ steps.shift()?.()
442
+ await vi.waitFor(() => {
443
+ expect(host.querySelector('.md-stub')?.textContent).toContain('答案')
444
+ })
445
+ expect(host.querySelector('.u-ai-chat__reasoning-title')?.textContent).toContain('思考过程')
446
+ expect(host.querySelector('.u-ai-chat__reasoning-line')).toBeFalsy()
447
+ unmount()
448
+ })
449
+
392
450
  it('思考过程流式结束后自动折叠并卸载内容 DOM', async () => {
393
451
  const transport: ChatTransport = (_req, handlers) => {
394
452
  handlers.onReasoningDelta?.('先分析一下问题')
@@ -513,6 +571,12 @@ describe('UAiChat', () => {
513
571
  await expandProcess(host)
514
572
 
515
573
  expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
574
+
575
+ // 完成后默认折叠,展开后 render 优先于插槽
576
+ const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
577
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
578
+ await nextTick()
579
+ await nextTick()
516
580
  expect(host.querySelector('.weather-view')).toBeTruthy()
517
581
  expect(host.querySelector('.slot-view')).toBeFalsy()
518
582
  unmount()
@@ -1170,7 +1234,7 @@ describe('UAiChat', () => {
1170
1234
  expect(
1171
1235
  host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
1172
1236
  ).toBe(true)
1173
- expect(host.querySelector('.u-ai-chat__input-usage')).toBeFalsy()
1237
+ expect(host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1174
1238
 
1175
1239
  chat.value?.send('你好')
1176
1240
  await vi.waitFor(() => {
@@ -1208,7 +1272,21 @@ describe('UAiChat', () => {
1208
1272
  unmount()
1209
1273
  })
1210
1274
 
1211
- it('有 usage 时默认只显示总 token,tokenUsageDetail 追加缓存命中/未命中', async () => {
1275
+ async function openUsagePanel(host: HTMLElement) {
1276
+ const trigger = host.querySelector<HTMLElement>('.u-ai-chat__usage-trigger')
1277
+ trigger?.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
1278
+ await nextTick()
1279
+ await nextTick()
1280
+ const panel = document.body.querySelector('.u-ai-chat__usage-panel')
1281
+ const rows: Record<string, string> = {}
1282
+ panel?.querySelectorAll('.u-ai-chat__usage-row').forEach((row) => {
1283
+ const label = row.querySelector('.u-ai-chat__usage-label')?.textContent ?? ''
1284
+ rows[label] = row.querySelector('.u-ai-chat__usage-value')?.textContent ?? ''
1285
+ })
1286
+ return rows
1287
+ }
1288
+
1289
+ it('有 usage 时显示用量环,悬停弹出明细;tokenUsageDetail 追加缓存命中/未命中', async () => {
1212
1290
  const usageTransport: ChatTransport = (_req, handlers) => {
1213
1291
  handlers.onTextDelta('ok')
1214
1292
  handlers.onUsage?.({
@@ -1221,27 +1299,28 @@ describe('UAiChat', () => {
1221
1299
  }
1222
1300
 
1223
1301
  const compact = mountAiChat({ transport: usageTransport })
1302
+ // 未收到 usage 时不渲染用量环
1303
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1224
1304
  compact.chat.value?.send('hi')
1225
1305
  await vi.waitFor(() => {
1226
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1306
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1227
1307
  })
1228
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain(
1229
- '缓存命中'
1230
- )
1308
+ const compactRows = await openUsagePanel(compact.host)
1309
+ expect(compactRows['总 token']).toBe('12')
1310
+ expect(compactRows['输入']).toBe('10')
1311
+ expect(compactRows['输出']).toBe('2')
1312
+ expect(compactRows['缓存命中']).toBeUndefined()
1231
1313
  compact.unmount()
1232
1314
 
1233
1315
  const detail = mountAiChat({ transport: usageTransport, tokenUsageDetail: true })
1234
1316
  detail.chat.value?.send('hi')
1235
1317
  await vi.waitFor(() => {
1236
- expect(detail.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1237
- '总 token 12 · 缓存命中 4 · 缓存未命中 6'
1238
- )
1318
+ expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1239
1319
  })
1240
- const text = detail.host.querySelector('.u-ai-chat__input-usage')?.textContent ?? ''
1241
- expect(text).not.toContain('本次')
1242
- expect(text).not.toContain('累计')
1243
- expect(text).not.toContain('输入')
1244
- expect(text).not.toContain('输出')
1320
+ const detailRows = await openUsagePanel(detail.host)
1321
+ expect(detailRows['总 token']).toBe('12')
1322
+ expect(detailRows['缓存命中']).toBe('4')
1323
+ expect(detailRows['缓存未命中']).toBe('6')
1245
1324
  detail.unmount()
1246
1325
  })
1247
1326
 
@@ -1259,10 +1338,12 @@ describe('UAiChat', () => {
1259
1338
  const scaled = mountAiChat({ transport: scaledTransport, tokenUsageDetail: true })
1260
1339
  scaled.chat.value?.send('hi')
1261
1340
  await vi.waitFor(() => {
1262
- expect(scaled.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1263
- '总 token 1.5K · 缓存命中 1K · 缓存未命中 1M'
1264
- )
1341
+ expect(scaled.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1265
1342
  })
1343
+ const scaledRows = await openUsagePanel(scaled.host)
1344
+ expect(scaledRows['总 token']).toBe('1.5K')
1345
+ expect(scaledRows['缓存命中']).toBe('1K')
1346
+ expect(scaledRows['缓存未命中']).toBe('1M')
1266
1347
  scaled.unmount()
1267
1348
 
1268
1349
  const noCacheTransport: ChatTransport = (_req, handlers) => {
@@ -1272,12 +1353,33 @@ describe('UAiChat', () => {
1272
1353
  const noCache = mountAiChat({ transport: noCacheTransport, tokenUsageDetail: true })
1273
1354
  noCache.chat.value?.send('hi')
1274
1355
  await vi.waitFor(() => {
1275
- expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1356
+ expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1276
1357
  })
1277
- expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain('缓存')
1358
+ const noCacheRows = await openUsagePanel(noCache.host)
1359
+ expect(noCacheRows['总 token']).toBe('12')
1360
+ expect(Object.keys(noCacheRows).some((label) => label.includes('缓存'))).toBe(false)
1278
1361
  noCache.unmount()
1279
1362
  })
1280
1363
 
1364
+ it('模型声明 contextWindow 时用量环按上下文占用填充,面板展示占比', async () => {
1365
+ const usageTransport: ChatTransport = (_req, handlers) => {
1366
+ handlers.onTextDelta('ok')
1367
+ handlers.onUsage?.({ promptTokens: 600, completionTokens: 400, totalTokens: 1000 })
1368
+ }
1369
+ const models: ChatModelOption[] = [
1370
+ { id: 'm1', label: 'M1', providerId: 'p1', contextWindow: 10000 }
1371
+ ]
1372
+ const { host, chat, unmount } = mountAiChat({ transport: usageTransport, models, model: 'm1' })
1373
+ chat.value?.send('hi')
1374
+ await vi.waitFor(() => {
1375
+ const ring = host.querySelector('.u-ai-chat__usage-ring-value')
1376
+ expect(ring?.getAttribute('stroke-dasharray')).toBe('10 100')
1377
+ })
1378
+ const rows = await openUsagePanel(host)
1379
+ expect(rows['上下文占用']).toBe('1K / 10K · 10%')
1380
+ unmount()
1381
+ })
1382
+
1281
1383
  const UNKNOWN_TOOL = 'totally-unknown-tool-xyz'
1282
1384
 
1283
1385
  function seedUnknownCall(init: Partial<ChatToolCall> = {}) {
@@ -1319,6 +1421,11 @@ describe('UAiChat', () => {
1319
1421
  expect(host.querySelector('.u-ai-chat__tool-call-name')?.classList.contains('u-shine')).toBe(
1320
1422
  true
1321
1423
  )
1424
+ // 进行中默认折叠
1425
+ expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
1426
+
1427
+ // 手动展开可查看参数
1428
+ await expandToolCard(host)
1322
1429
  expect(host.textContent).toContain('"q": "hi"')
1323
1430
 
1324
1431
  call.status = 'success'
@@ -1326,7 +1433,7 @@ describe('UAiChat', () => {
1326
1433
  await nextTick()
1327
1434
  assertGenericCard(host, 'success')
1328
1435
 
1329
- await expandToolCard(host)
1436
+ // 用户已手动展开过,进入终态后保持展开
1330
1437
  expect(host.textContent).toContain('"ok": true')
1331
1438
  unmount()
1332
1439
  })
@@ -53,6 +53,7 @@
53
53
  :max-attachment-size="maxAttachmentSize"
54
54
  :clearable="clearable"
55
55
  :token-usage="tokenUsage"
56
+ :last-request-usage="lastRequestUsage"
56
57
  :token-usage-detail="tokenUsageDetail"
57
58
  @send="handleSend"
58
59
  @abort="abort"
@@ -133,6 +134,7 @@ const {
133
134
  jobs,
134
135
  tokenUsage,
135
136
  lastTurnUsage,
137
+ lastRequestUsage,
136
138
  pendingApprovals,
137
139
  pendingQuestion,
138
140
  send,
@@ -46,14 +46,48 @@
46
46
  </template>
47
47
  </UPopConfirm>
48
48
  </span>
49
-
50
- <span v-if="tokenUsage" :class="cls.e('input-usage')" :title="usageText">
51
- {{ usageText }}
52
- </span>
53
49
  </div>
54
50
 
55
- <!-- 右簇:模型/推理选择 → 发送或停止(互斥) -->
51
+ <!-- 右簇:token 用量环 → 模型/推理选择 → 发送或停止(互斥) -->
56
52
  <div :class="cls.e('input-toolbar-right')">
53
+ <UDropdown
54
+ v-if="tokenUsage"
55
+ width="auto"
56
+ min-width="200px"
57
+ :content-class="cls.e('usage-panel')"
58
+ >
59
+ <template #trigger>
60
+ <button type="button" :class="cls.e('usage-trigger')" aria-label="Token 用量">
61
+ <svg :class="cls.e('usage-ring')" viewBox="0 0 36 36">
62
+ <circle :class="cls.e('usage-ring-track')" cx="18" cy="18" r="15.5" />
63
+ <circle
64
+ :class="cls.e('usage-ring-value')"
65
+ cx="18"
66
+ cy="18"
67
+ r="15.5"
68
+ pathLength="100"
69
+ transform="rotate(-90 18 18)"
70
+ :stroke-dasharray="`${contextPercent ?? 0} 100`"
71
+ :stroke-linecap="contextPercent ? 'round' : undefined"
72
+ />
73
+ </svg>
74
+ </button>
75
+ </template>
76
+
77
+ <template #content>
78
+ <div :class="cls.e('usage-list')">
79
+ <div v-if="contextText" :class="cls.e('usage-row')">
80
+ <span :class="cls.e('usage-label')">上下文占用</span>
81
+ <span :class="cls.e('usage-value')">{{ contextText }}</span>
82
+ </div>
83
+ <div v-for="row in usageRows" :key="row.label" :class="cls.e('usage-row')">
84
+ <span :class="cls.e('usage-label')">{{ row.label }}</span>
85
+ <span :class="cls.e('usage-value')">{{ row.value }}</span>
86
+ </div>
87
+ </div>
88
+ </template>
89
+ </UDropdown>
90
+
57
91
  <ModelPicker
58
92
  v-if="models?.length"
59
93
  v-model:model="model"
@@ -89,7 +123,7 @@
89
123
  </template>
90
124
 
91
125
  <script lang="ts" setup>
92
- import { UButton, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
126
+ import { UButton, UDropdown, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
93
127
  import { Attach, Clear, Close, Up } from '@veltra/icons/normal'
94
128
  import { bem } from '@veltra/utils'
95
129
  import { computed, inject, nextTick, ref, shallowRef, watch } from 'vue'
@@ -116,6 +150,8 @@ const props = defineProps<{
116
150
  clearable?: boolean
117
151
  /** 会话累计 token;无 usage 时为 null,不展示 */
118
152
  tokenUsage?: ChatTokenUsage | null
153
+ /** 最近一次单次请求的 token(未累计);配合模型 contextWindow 计算用量环占比 */
154
+ lastRequestUsage?: ChatTokenUsage | null
119
155
  /** 是否展示缓存命中 / 未命中(默认只显示总 token) */
120
156
  tokenUsageDetail?: boolean
121
157
  }>()
@@ -159,20 +195,52 @@ const formatTokenCount = (n: number) => {
159
195
  return scaled(n / 1_000_000, 'M')
160
196
  }
161
197
 
162
- /** 会话累计:总 token;明细再拼有数据的缓存项,缺字段不写 0 */
163
- const usageText = computed(() => {
198
+ /** 百分比:最多 1 位小数,整数不写 `.0` */
199
+ const formatPercent = (p: number) => {
200
+ const rounded = Math.round(p * 10) / 10
201
+ return `${Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)}%`
202
+ }
203
+
204
+ /** 当前模型声明的上下文窗口;未声明则用量环不填充、面板不显示占用 */
205
+ const contextWindow = computed(() => {
206
+ return props.models?.find((m) => m.id === model.value)?.contextWindow
207
+ })
208
+
209
+ /** 上下文占用百分比:最近一次请求总 token / 窗口上限;缺数据时为 null */
210
+ const contextPercent = computed(() => {
211
+ const win = contextWindow.value
212
+ const total = props.lastRequestUsage?.totalTokens
213
+ if (!win || total == null) return null
214
+ return Math.min(100, (total / win) * 100)
215
+ })
216
+
217
+ /** 面板占用行文案:1.5K / 128K · 1.2% */
218
+ const contextText = computed(() => {
219
+ const percent = contextPercent.value
220
+ const win = contextWindow.value
221
+ const total = props.lastRequestUsage?.totalTokens
222
+ if (percent == null || !win || total == null) return ''
223
+ return `${formatTokenCount(total)} / ${formatTokenCount(win)} · ${formatPercent(percent)}`
224
+ })
225
+
226
+ /** 明细面板行:总 / 输入 / 输出;明细开关下再拼有数据的缓存项,缺字段不写 0 */
227
+ const usageRows = computed(() => {
164
228
  const usage = props.tokenUsage
165
- if (!usage) return ''
166
- const parts = [`总 token ${formatTokenCount(usage.totalTokens)}`]
229
+ if (!usage) return []
230
+ const rows = [
231
+ { label: '总 token', value: formatTokenCount(usage.totalTokens) },
232
+ { label: '输入', value: formatTokenCount(usage.promptTokens) },
233
+ { label: '输出', value: formatTokenCount(usage.completionTokens) }
234
+ ]
167
235
  if (props.tokenUsageDetail) {
168
236
  if (usage.cacheHitTokens != null) {
169
- parts.push(`缓存命中 ${formatTokenCount(usage.cacheHitTokens)}`)
237
+ rows.push({ label: '缓存命中', value: formatTokenCount(usage.cacheHitTokens) })
170
238
  }
171
239
  if (usage.cacheMissTokens != null) {
172
- parts.push(`缓存未命中 ${formatTokenCount(usage.cacheMissTokens)}`)
240
+ rows.push({ label: '缓存未命中', value: formatTokenCount(usage.cacheMissTokens) })
173
241
  }
174
242
  }
175
- return parts.join(' · ')
243
+ return rows
176
244
  })
177
245
 
178
246
  /** 生成中提示用户消息将进入队列 */
@@ -0,0 +1,8 @@
1
+ /** 格式化工作时长:整秒显示,≥60s 显示 Xm Ys */
2
+ export const formatDuration = (ms: number): string => {
3
+ const seconds = Math.round(ms / 1000)
4
+ if (seconds < 60) return `${seconds}s`
5
+ const minutes = Math.floor(seconds / 60)
6
+ const rest = seconds % 60
7
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`
8
+ }
@@ -32,6 +32,10 @@
32
32
  <span :class="[cls.e('reasoning-title'), isThinking && 'u-shine']">
33
33
  {{ isThinking ? '思考中…' : '思考过程' }}
34
34
  </span>
35
+ <!-- 折叠态在头部右侧滚动展示最新一行思考 -->
36
+ <span v-if="latestReasoningLine" :class="cls.e('reasoning-line')">
37
+ {{ latestReasoningLine }}
38
+ </span>
35
39
  <UIcon :class="cls.e('reasoning-chevron')"><ArrowRight /></UIcon>
36
40
  </div>
37
41
  <!-- 折叠时完全卸载内容 DOM:思考文本可能很长,常驻挂载浪费内存与渲染成本 -->
@@ -69,6 +73,9 @@
69
73
  </div>
70
74
 
71
75
  <div v-if="showActions" :class="cls.e('message-actions')">
76
+ <span v-if="duration != null" :class="cls.e('message-duration')">
77
+ 用时 {{ formatDuration(duration) }}
78
+ </span>
72
79
  <UButton
73
80
  size="small"
74
81
  plain
@@ -103,6 +110,7 @@ import { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch
103
110
 
104
111
  import type { ChatMessage } from '../../chat/types'
105
112
  import { AiChatDIKey } from './di'
113
+ import { formatDuration } from './format-duration'
106
114
  import ToolCallCard from './tool-call.vue'
107
115
 
108
116
  defineOptions({ name: 'UAiChatMessageItem' })
@@ -111,6 +119,8 @@ const props = defineProps<{
111
119
  message: ChatMessage
112
120
  /** 是否为最后一条可见消息(决定是否显示重新生成) */
113
121
  isLast: boolean
122
+ /** 本轮总用时(ms;仅 live 轮次有,历史回放无计时数据) */
123
+ duration?: number
114
124
  /** 透传给 MarkdownRender 的属性 */
115
125
  rendererProps?: Record<string, unknown>
116
126
  }>()
@@ -127,12 +137,24 @@ const isStreaming = computed(() => props.message.status === 'streaming')
127
137
  /** 思考中:流式且正文还未开始输出 */
128
138
  const isThinking = computed(() => isStreaming.value && !props.message.content)
129
139
 
130
- const reasoningExpanded = ref(true)
140
+ /** 思考默认折叠(含流式进行中),用户点击头部展开 */
141
+ const reasoningExpanded = ref(false)
142
+
143
+ /** 折叠时头部右侧滚动展示的最新一行思考:取最后一行非空文本,随流式输出持续刷新 */
144
+ const latestReasoningLine = computed(() => {
145
+ if (!isThinking.value || reasoningExpanded.value) return ''
146
+ const lines = (props.message.reasoning ?? '').split('\n')
147
+ for (let i = lines.length - 1; i >= 0; i--) {
148
+ const line = lines[i]?.trim()
149
+ if (line) return line
150
+ }
151
+ return ''
152
+ })
131
153
 
132
154
  /** 思考区吸附底部:用户在思考区内上滚时取消吸附,回到底部后恢复 */
133
155
  const reasoningStick = ref(true)
134
156
 
135
- /** 思考内容滚动区:流式输出时吸附底部,始终展示最新思考 */
157
+ /** 思考内容滚动区:展开且流式输出时吸附底部,始终展示最新思考 */
136
158
  const reasoningScrollRef = useTemplateRef<InstanceType<typeof UScroll>>('reasoningScrollRef')
137
159
 
138
160
  const toggleReasoning = async () => {
@@ -152,20 +174,6 @@ const handleReasoningScroll = (position: Required<ScrollPosition>) => {
152
174
  if (position.sh - position.ch - position.y < 8) reasoningStick.value = true
153
175
  }
154
176
 
155
- // 流式结束后自动折叠思考过程;immediate 保证终态消息重挂载(过程块展开/收起、载入历史会话)时呈折叠态
156
- watch(
157
- () => props.message.status,
158
- (status) => {
159
- if (status === 'streaming') {
160
- reasoningExpanded.value = true
161
- reasoningStick.value = true
162
- } else if (status) {
163
- reasoningExpanded.value = false
164
- }
165
- },
166
- { immediate: true }
167
- )
168
-
169
177
  watch(
170
178
  () => props.message.reasoning,
171
179
  async () => {
@@ -2,8 +2,8 @@
2
2
  <div :class="cls.e('list-wrap')">
3
3
  <UScroll
4
4
  :class="cls.e('list')"
5
+ :content-class="cls.e('list-content')"
5
6
  ref="scrollRef"
6
- always
7
7
  @scroll="handleScroll"
8
8
  @wheel.passive="handleWheel"
9
9
  >
@@ -40,6 +40,7 @@
40
40
  v-if="turn.answerMsg"
41
41
  :message="turn.answerMsg"
42
42
  :is-last="turn.isLast"
43
+ :duration="turnDurations.get(turn.key)"
43
44
  :renderer-props="rendererProps"
44
45
  @respond="(id, approved) => emit('respond', id, approved)"
45
46
  @regenerate="emit('regenerate')"
@@ -49,7 +50,13 @@
49
50
  <!-- 工作中:活体球从输入框上方跳到最新会话下面;结束停留后再跳回 -->
50
51
  <div v-if="showWorking" :class="[cls.e('working'), bem.is('leaving', workingLeaving)]">
51
52
  <UAiOrb ref="workingOrb" :size="ORB_SIZE" :status="workingOrbStatus" />
52
- <span v-if="running" :class="[cls.e('working-text'), 'u-shine']">工作中…</span>
53
+ <template v-if="running">
54
+ <span :class="[cls.e('working-text'), 'u-shine']">工作中…</span>
55
+ <span :class="cls.e('working-time')">{{ formatDuration(elapsedMs) }}</span>
56
+ </template>
57
+ <span v-else-if="lastTurnDuration != null" :class="cls.e('working-time')">
58
+ 用时 {{ formatDuration(lastTurnDuration) }}
59
+ </span>
53
60
  </div>
54
61
  </UScroll>
55
62
 
@@ -83,9 +90,14 @@
83
90
 
84
91
  <!-- 用户上翻浏览时悬浮入口:一键回到最新消息 -->
85
92
  <Transition :name="cls.em('to-latest', 'fade')">
86
- <button v-if="showToLatest" type="button" :class="cls.e('to-latest')" @click="scrollToLatest">
93
+ <button
94
+ v-if="showToLatest"
95
+ type="button"
96
+ :class="cls.e('to-latest')"
97
+ title="回到最新"
98
+ @click="scrollToLatest"
99
+ >
87
100
  <UIcon><ArrowDown /></UIcon>
88
- <span>最新消息</span>
89
101
  </button>
90
102
  </Transition>
91
103
  </div>
@@ -111,6 +123,7 @@ import type { ChatMessage } from '../../chat/types'
111
123
  import type { AiOrbExposed, AiOrbReaction, AiOrbStatus } from '../../types/ai-orb'
112
124
  import UAiOrb from '../ai-orb/ai-orb.vue'
113
125
  import { AiChatDIKey } from './di'
126
+ import { formatDuration } from './format-duration'
114
127
  import MessageItem from './message-item.vue'
115
128
  import TurnProcess from './turn-process.vue'
116
129
 
@@ -258,6 +271,26 @@ const clearJumpTimers = () => {
258
271
  clearTimeout(jumpTimer)
259
272
  }
260
273
 
274
+ /** 工作计时的起算时间戳(running 升起时记录) */
275
+ let workStartAt = 0
276
+ /** 进行中的已逝时长(100ms 跳动刷新) */
277
+ const elapsedMs = ref(0)
278
+ let tickTimer: ReturnType<typeof setInterval> | undefined
279
+
280
+ /** 各轮次总用时(key 同 turns;仅 live 轮次有,历史回放无计时数据) */
281
+ const turnDurations = shallowRef(new Map<string, number>())
282
+
283
+ const stopTick = () => {
284
+ clearInterval(tickTimer)
285
+ tickTimer = undefined
286
+ }
287
+
288
+ /** 收尾停留期间展示的末轮总用时 */
289
+ const lastTurnDuration = computed(() => {
290
+ const last = turns.value[turns.value.length - 1]
291
+ return last ? turnDurations.value.get(last.key) : undefined
292
+ })
293
+
261
294
  /** 列表末尾的工作球:生成中 / 收尾停留 / 跳回过渡 */
262
295
  const showWorking = computed(() => !!(props.running || finishing.value || workingLeaving.value))
263
296
  /** 输入框上方的空闲欢迎:含工作开始时的缩小离开帧 */
@@ -304,6 +337,13 @@ watch(
304
337
  if (running) {
305
338
  // 工作开始:立即从输入框上方跳到列表,无停留
306
339
  clearJumpTimers()
340
+ // 工作计时开始:100ms 跳动刷新已逝时长
341
+ workStartAt = Date.now()
342
+ elapsedMs.value = 0
343
+ stopTick()
344
+ tickTimer = setInterval(() => {
345
+ elapsedMs.value = Date.now() - workStartAt
346
+ }, 100)
307
347
  const idleWasVisible = !prev && !finishing.value && !workingLeaving.value
308
348
  finishing.value = false
309
349
  workingLeaving.value = false
@@ -320,6 +360,15 @@ watch(
320
360
  }
321
361
  if (!prev) return
322
362
 
363
+ stopTick()
364
+ // 记录本轮总用时(key 同 turns:regenerate 同 key 覆盖,队列接续各自计时)
365
+ const lastTurn = turns.value[turns.value.length - 1]
366
+ if (lastTurn) {
367
+ const next = new Map(turnDurations.value)
368
+ next.set(lastTurn.key, Date.now() - workStartAt)
369
+ turnDurations.value = next
370
+ }
371
+
323
372
  // 清除会话后列表已空:跳过结束停留,立刻回到欢迎区
324
373
  if (visibleMessages.value.length === 0) {
325
374
  finishing.value = false
@@ -359,6 +408,7 @@ watch(
359
408
  (len) => {
360
409
  if (len > 0) return
361
410
  clearJumpTimers()
411
+ turnDurations.value = new Map()
362
412
  finishing.value = false
363
413
  workingLeaving.value = false
364
414
  idleLeaving.value = false
@@ -379,6 +429,7 @@ watch(
379
429
  onBeforeUnmount(() => {
380
430
  stopWelcomeRotation()
381
431
  clearJumpTimers()
432
+ stopTick()
382
433
  })
383
434
 
384
435
  const scrollRef = shallowRef<{ scrollTo: (position: ScrollPosition) => void }>()
@@ -387,7 +438,7 @@ const stickToBottom = ref(true)
387
438
  /** 上一次滚动位置:用于区分用户上翻与自动吸底触发的 scroll 事件 */
388
439
  let lastScrollY = 0
389
440
 
390
- /** 上翻阅读时展示「最新消息」悬浮按钮 */
441
+ /** 上翻阅读时展示回到底部的悬浮按钮 */
391
442
  const showToLatest = computed(() => !stickToBottom.value && visibleMessages.value.length > 0)
392
443
 
393
444
  const handleScroll = (position: Required<ScrollPosition>) => {