@veltra/ai 2.1.10 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) 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 +160 -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 +32 -23
  11. package/dist/components/ai-chat/message-item.js.map +1 -1
  12. package/dist/components/ai-chat/message-list.js +40 -5
  13. package/dist/components/ai-chat/message-list.js.map +1 -1
  14. package/dist/components/ai-chat/model-picker.js +31 -95
  15. package/dist/components/ai-chat/model-picker.js.map +1 -1
  16. package/dist/components/ai-chat/reasoning-picker.js +68 -0
  17. package/dist/components/ai-chat/reasoning-picker.js.map +1 -0
  18. package/dist/components/ai-chat/style2.css +123 -68
  19. package/dist/providers/types.d.ts +2 -0
  20. package/dist/types/ai-chat.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/chat/use-chat.ts +6 -0
  23. package/src/components/ai-chat/__test__/ai-chat.test.ts +79 -28
  24. package/src/components/ai-chat/ai-chat.vue +2 -0
  25. package/src/components/ai-chat/chat-input.vue +91 -16
  26. package/src/components/ai-chat/format-duration.ts +8 -0
  27. package/src/components/ai-chat/message-item.vue +6 -0
  28. package/src/components/ai-chat/message-list.vue +48 -1
  29. package/src/components/ai-chat/model-picker.vue +11 -110
  30. package/src/components/ai-chat/reasoning-picker.vue +70 -0
  31. package/src/components/ai-chat/style.scss +137 -72
  32. package/src/providers/types.ts +2 -0
  33. package/src/types/ai-chat.ts +2 -1
@@ -870,7 +870,7 @@ describe('UAiChat', () => {
870
870
  unmount()
871
871
  })
872
872
 
873
- it('有 models 时渲染模型选择器;当前模型无 reasoningLevels 时不展示推理等级', async () => {
873
+ it('有 models 时渲染模型选择器;当前模型无 reasoningLevels 时不展示推理选择器', async () => {
874
874
  const { host, unmount } = mountAiChat({
875
875
  transport: () => {},
876
876
  models: [
@@ -897,12 +897,12 @@ describe('UAiChat', () => {
897
897
  expect(
898
898
  host.querySelector('.u-ai-chat__input-toolbar-left .u-ai-chat__model-trigger')
899
899
  ).toBeFalsy()
900
- // 当前模型无 reasoningLevels,触发器不展示推理等级
901
- expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
900
+ // 当前模型无 reasoningLevels,独立推理选择器不渲染
901
+ expect(host.querySelector('.u-ai-chat__reasoning-trigger')).toBeFalsy()
902
902
  unmount()
903
903
  })
904
904
 
905
- it('切换到带 reasoningLevels 的模型后触发器展示推理等级', async () => {
905
+ it('切换到带 reasoningLevels 的模型后展示独立推理选择器并可切换等级', async () => {
906
906
  const model = ref('gpt-4o')
907
907
  const reasoningLevel = ref<string | undefined>()
908
908
  const host = document.createElement('div')
@@ -940,18 +940,31 @@ describe('UAiChat', () => {
940
940
  app.mount(host)
941
941
  await nextTick()
942
942
 
943
- expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
943
+ expect(host.querySelector('.u-ai-chat__reasoning-trigger')).toBeFalsy()
944
944
 
945
945
  model.value = 'o3-mini'
946
946
  await nextTick()
947
947
 
948
- const reasoning = host.querySelector(
949
- '.u-ai-chat__input-toolbar-right .u-ai-chat__model-trigger-reasoning'
948
+ const trigger = host.querySelector<HTMLElement>(
949
+ '.u-ai-chat__input-toolbar-right .u-ai-chat__reasoning-trigger'
950
950
  )
951
- expect(reasoning).toBeTruthy()
952
- expect(reasoning?.textContent).toBe('低')
951
+ expect(trigger).toBeTruthy()
952
+ expect(trigger?.textContent).toContain('低')
953
953
  expect(reasoningLevel.value).toBe('low')
954
954
 
955
+ // 打开面板切换等级;面板 teleport 到 body 下的 popper 容器
956
+ trigger!.click()
957
+ await vi.waitFor(() => {
958
+ expect(document.querySelector('.u-ai-chat__reasoning-option')).toBeTruthy()
959
+ })
960
+ const highOption = [
961
+ ...document.querySelectorAll<HTMLElement>('.u-ai-chat__reasoning-option')
962
+ ].find((el) => el.textContent?.includes('高'))
963
+ highOption!.click()
964
+ await nextTick()
965
+ expect(reasoningLevel.value).toBe('high')
966
+ expect(trigger?.textContent).toContain('高')
967
+
955
968
  app.unmount()
956
969
  host.remove()
957
970
  })
@@ -1234,7 +1247,7 @@ describe('UAiChat', () => {
1234
1247
  expect(
1235
1248
  host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
1236
1249
  ).toBe(true)
1237
- expect(host.querySelector('.u-ai-chat__input-usage')).toBeFalsy()
1250
+ expect(host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1238
1251
 
1239
1252
  chat.value?.send('你好')
1240
1253
  await vi.waitFor(() => {
@@ -1272,7 +1285,21 @@ describe('UAiChat', () => {
1272
1285
  unmount()
1273
1286
  })
1274
1287
 
1275
- it('有 usage 时默认只显示总 token,tokenUsageDetail 追加缓存命中/未命中', async () => {
1288
+ async function openUsagePanel(host: HTMLElement) {
1289
+ const trigger = host.querySelector<HTMLElement>('.u-ai-chat__usage-trigger')
1290
+ trigger?.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
1291
+ await nextTick()
1292
+ await nextTick()
1293
+ const panel = document.body.querySelector('.u-ai-chat__usage-panel')
1294
+ const rows: Record<string, string> = {}
1295
+ panel?.querySelectorAll('.u-ai-chat__usage-row').forEach((row) => {
1296
+ const label = row.querySelector('.u-ai-chat__usage-label')?.textContent ?? ''
1297
+ rows[label] = row.querySelector('.u-ai-chat__usage-value')?.textContent ?? ''
1298
+ })
1299
+ return rows
1300
+ }
1301
+
1302
+ it('有 usage 时显示用量环,悬停弹出明细;tokenUsageDetail 追加缓存命中/未命中', async () => {
1276
1303
  const usageTransport: ChatTransport = (_req, handlers) => {
1277
1304
  handlers.onTextDelta('ok')
1278
1305
  handlers.onUsage?.({
@@ -1285,27 +1312,28 @@ describe('UAiChat', () => {
1285
1312
  }
1286
1313
 
1287
1314
  const compact = mountAiChat({ transport: usageTransport })
1315
+ // 未收到 usage 时不渲染用量环
1316
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1288
1317
  compact.chat.value?.send('hi')
1289
1318
  await vi.waitFor(() => {
1290
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1319
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1291
1320
  })
1292
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain(
1293
- '缓存命中'
1294
- )
1321
+ const compactRows = await openUsagePanel(compact.host)
1322
+ expect(compactRows['总 token']).toBe('12')
1323
+ expect(compactRows['输入']).toBe('10')
1324
+ expect(compactRows['输出']).toBe('2')
1325
+ expect(compactRows['缓存命中']).toBeUndefined()
1295
1326
  compact.unmount()
1296
1327
 
1297
1328
  const detail = mountAiChat({ transport: usageTransport, tokenUsageDetail: true })
1298
1329
  detail.chat.value?.send('hi')
1299
1330
  await vi.waitFor(() => {
1300
- expect(detail.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1301
- '总 token 12 · 缓存命中 4 · 缓存未命中 6'
1302
- )
1331
+ expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1303
1332
  })
1304
- const text = detail.host.querySelector('.u-ai-chat__input-usage')?.textContent ?? ''
1305
- expect(text).not.toContain('本次')
1306
- expect(text).not.toContain('累计')
1307
- expect(text).not.toContain('输入')
1308
- expect(text).not.toContain('输出')
1333
+ const detailRows = await openUsagePanel(detail.host)
1334
+ expect(detailRows['总 token']).toBe('12')
1335
+ expect(detailRows['缓存命中']).toBe('4')
1336
+ expect(detailRows['缓存未命中']).toBe('6')
1309
1337
  detail.unmount()
1310
1338
  })
1311
1339
 
@@ -1323,10 +1351,12 @@ describe('UAiChat', () => {
1323
1351
  const scaled = mountAiChat({ transport: scaledTransport, tokenUsageDetail: true })
1324
1352
  scaled.chat.value?.send('hi')
1325
1353
  await vi.waitFor(() => {
1326
- expect(scaled.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1327
- '总 token 1.5K · 缓存命中 1K · 缓存未命中 1M'
1328
- )
1354
+ expect(scaled.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1329
1355
  })
1356
+ const scaledRows = await openUsagePanel(scaled.host)
1357
+ expect(scaledRows['总 token']).toBe('1.5K')
1358
+ expect(scaledRows['缓存命中']).toBe('1K')
1359
+ expect(scaledRows['缓存未命中']).toBe('1M')
1330
1360
  scaled.unmount()
1331
1361
 
1332
1362
  const noCacheTransport: ChatTransport = (_req, handlers) => {
@@ -1336,12 +1366,33 @@ describe('UAiChat', () => {
1336
1366
  const noCache = mountAiChat({ transport: noCacheTransport, tokenUsageDetail: true })
1337
1367
  noCache.chat.value?.send('hi')
1338
1368
  await vi.waitFor(() => {
1339
- expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1369
+ expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1340
1370
  })
1341
- expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain('缓存')
1371
+ const noCacheRows = await openUsagePanel(noCache.host)
1372
+ expect(noCacheRows['总 token']).toBe('12')
1373
+ expect(Object.keys(noCacheRows).some((label) => label.includes('缓存'))).toBe(false)
1342
1374
  noCache.unmount()
1343
1375
  })
1344
1376
 
1377
+ it('模型声明 contextWindow 时用量环按上下文占用填充,面板展示占比', async () => {
1378
+ const usageTransport: ChatTransport = (_req, handlers) => {
1379
+ handlers.onTextDelta('ok')
1380
+ handlers.onUsage?.({ promptTokens: 600, completionTokens: 400, totalTokens: 1000 })
1381
+ }
1382
+ const models: ChatModelOption[] = [
1383
+ { id: 'm1', label: 'M1', providerId: 'p1', contextWindow: 10000 }
1384
+ ]
1385
+ const { host, chat, unmount } = mountAiChat({ transport: usageTransport, models, model: 'm1' })
1386
+ chat.value?.send('hi')
1387
+ await vi.waitFor(() => {
1388
+ const ring = host.querySelector('.u-ai-chat__usage-ring-value')
1389
+ expect(ring?.getAttribute('stroke-dasharray')).toBe('10 100')
1390
+ })
1391
+ const rows = await openUsagePanel(host)
1392
+ expect(rows['上下文占用']).toBe('1K / 10K · 10%')
1393
+ unmount()
1394
+ })
1395
+
1345
1396
  const UNKNOWN_TOOL = 'totally-unknown-tool-xyz'
1346
1397
 
1347
1398
  function seedUnknownCall(init: Partial<ChatToolCall> = {}) {
@@ -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,19 +46,54 @@
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')">
57
- <ModelPicker
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
+
91
+ <ModelPicker v-if="models?.length" v-model:model="model" :models="models" />
92
+
93
+ <ReasoningPicker
58
94
  v-if="models?.length"
59
- v-model:model="model"
60
95
  v-model:reasoning-level="reasoningLevel"
61
- :models="models"
96
+ :levels="reasoningLevels"
62
97
  />
63
98
 
64
99
  <UButton
@@ -89,7 +124,7 @@
89
124
  </template>
90
125
 
91
126
  <script lang="ts" setup>
92
- import { UButton, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
127
+ import { UButton, UDropdown, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
93
128
  import { Attach, Clear, Close, Up } from '@veltra/icons/normal'
94
129
  import { bem } from '@veltra/utils'
95
130
  import { computed, inject, nextTick, ref, shallowRef, watch } from 'vue'
@@ -98,6 +133,7 @@ import type { ChatAttachment, ChatTokenUsage } from '../../chat/types'
98
133
  import type { ChatModelOption } from '../../providers'
99
134
  import { AiChatDIKey } from './di'
100
135
  import ModelPicker from './model-picker.vue'
136
+ import ReasoningPicker from './reasoning-picker.vue'
101
137
 
102
138
  defineOptions({ name: 'UAiChatInput' })
103
139
 
@@ -116,6 +152,8 @@ const props = defineProps<{
116
152
  clearable?: boolean
117
153
  /** 会话累计 token;无 usage 时为 null,不展示 */
118
154
  tokenUsage?: ChatTokenUsage | null
155
+ /** 最近一次单次请求的 token(未累计);配合模型 contextWindow 计算用量环占比 */
156
+ lastRequestUsage?: ChatTokenUsage | null
119
157
  /** 是否展示缓存命中 / 未命中(默认只显示总 token) */
120
158
  tokenUsageDetail?: boolean
121
159
  }>()
@@ -123,6 +161,11 @@ const props = defineProps<{
123
161
  const model = defineModel<string>('model')
124
162
  const reasoningLevel = defineModel<string>('reasoningLevel')
125
163
 
164
+ /** 当前模型的推理等级列表,交给独立选择器;不足两档时选择器自身不渲染 */
165
+ const reasoningLevels = computed(() => {
166
+ return props.models?.find((m) => m.id === model.value)?.reasoningLevels ?? []
167
+ })
168
+
126
169
  const emit = defineEmits<{
127
170
  (e: 'send', content: string, attachments: ChatAttachment[]): void
128
171
  (e: 'abort'): void
@@ -159,20 +202,52 @@ const formatTokenCount = (n: number) => {
159
202
  return scaled(n / 1_000_000, 'M')
160
203
  }
161
204
 
162
- /** 会话累计:总 token;明细再拼有数据的缓存项,缺字段不写 0 */
163
- const usageText = computed(() => {
205
+ /** 百分比:最多 1 位小数,整数不写 `.0` */
206
+ const formatPercent = (p: number) => {
207
+ const rounded = Math.round(p * 10) / 10
208
+ return `${Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)}%`
209
+ }
210
+
211
+ /** 当前模型声明的上下文窗口;未声明则用量环不填充、面板不显示占用 */
212
+ const contextWindow = computed(() => {
213
+ return props.models?.find((m) => m.id === model.value)?.contextWindow
214
+ })
215
+
216
+ /** 上下文占用百分比:最近一次请求总 token / 窗口上限;缺数据时为 null */
217
+ const contextPercent = computed(() => {
218
+ const win = contextWindow.value
219
+ const total = props.lastRequestUsage?.totalTokens
220
+ if (!win || total == null) return null
221
+ return Math.min(100, (total / win) * 100)
222
+ })
223
+
224
+ /** 面板占用行文案:1.5K / 128K · 1.2% */
225
+ const contextText = computed(() => {
226
+ const percent = contextPercent.value
227
+ const win = contextWindow.value
228
+ const total = props.lastRequestUsage?.totalTokens
229
+ if (percent == null || !win || total == null) return ''
230
+ return `${formatTokenCount(total)} / ${formatTokenCount(win)} · ${formatPercent(percent)}`
231
+ })
232
+
233
+ /** 明细面板行:总 / 输入 / 输出;明细开关下再拼有数据的缓存项,缺字段不写 0 */
234
+ const usageRows = computed(() => {
164
235
  const usage = props.tokenUsage
165
- if (!usage) return ''
166
- const parts = [`总 token ${formatTokenCount(usage.totalTokens)}`]
236
+ if (!usage) return []
237
+ const rows = [
238
+ { label: '总 token', value: formatTokenCount(usage.totalTokens) },
239
+ { label: '输入', value: formatTokenCount(usage.promptTokens) },
240
+ { label: '输出', value: formatTokenCount(usage.completionTokens) }
241
+ ]
167
242
  if (props.tokenUsageDetail) {
168
243
  if (usage.cacheHitTokens != null) {
169
- parts.push(`缓存命中 ${formatTokenCount(usage.cacheHitTokens)}`)
244
+ rows.push({ label: '缓存命中', value: formatTokenCount(usage.cacheHitTokens) })
170
245
  }
171
246
  if (usage.cacheMissTokens != null) {
172
- parts.push(`缓存未命中 ${formatTokenCount(usage.cacheMissTokens)}`)
247
+ rows.push({ label: '缓存未命中', value: formatTokenCount(usage.cacheMissTokens) })
173
248
  }
174
249
  }
175
- return parts.join(' · ')
250
+ return rows
176
251
  })
177
252
 
178
253
  /** 生成中提示用户消息将进入队列 */
@@ -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
+ }
@@ -73,6 +73,9 @@
73
73
  </div>
74
74
 
75
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>
76
79
  <UButton
77
80
  size="small"
78
81
  plain
@@ -107,6 +110,7 @@ import { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch
107
110
 
108
111
  import type { ChatMessage } from '../../chat/types'
109
112
  import { AiChatDIKey } from './di'
113
+ import { formatDuration } from './format-duration'
110
114
  import ToolCallCard from './tool-call.vue'
111
115
 
112
116
  defineOptions({ name: 'UAiChatMessageItem' })
@@ -115,6 +119,8 @@ const props = defineProps<{
115
119
  message: ChatMessage
116
120
  /** 是否为最后一条可见消息(决定是否显示重新生成) */
117
121
  isLast: boolean
122
+ /** 本轮总用时(ms;仅 live 轮次有,历史回放无计时数据) */
123
+ duration?: number
118
124
  /** 透传给 MarkdownRender 的属性 */
119
125
  rendererProps?: Record<string, unknown>
120
126
  }>()
@@ -2,6 +2,7 @@
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
7
  @scroll="handleScroll"
7
8
  @wheel.passive="handleWheel"
@@ -39,6 +40,7 @@
39
40
  v-if="turn.answerMsg"
40
41
  :message="turn.answerMsg"
41
42
  :is-last="turn.isLast"
43
+ :duration="turnDurations.get(turn.key)"
42
44
  :renderer-props="rendererProps"
43
45
  @respond="(id, approved) => emit('respond', id, approved)"
44
46
  @regenerate="emit('regenerate')"
@@ -48,7 +50,13 @@
48
50
  <!-- 工作中:活体球从输入框上方跳到最新会话下面;结束停留后再跳回 -->
49
51
  <div v-if="showWorking" :class="[cls.e('working'), bem.is('leaving', workingLeaving)]">
50
52
  <UAiOrb ref="workingOrb" :size="ORB_SIZE" :status="workingOrbStatus" />
51
- <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>
52
60
  </div>
53
61
  </UScroll>
54
62
 
@@ -115,6 +123,7 @@ import type { ChatMessage } from '../../chat/types'
115
123
  import type { AiOrbExposed, AiOrbReaction, AiOrbStatus } from '../../types/ai-orb'
116
124
  import UAiOrb from '../ai-orb/ai-orb.vue'
117
125
  import { AiChatDIKey } from './di'
126
+ import { formatDuration } from './format-duration'
118
127
  import MessageItem from './message-item.vue'
119
128
  import TurnProcess from './turn-process.vue'
120
129
 
@@ -262,6 +271,26 @@ const clearJumpTimers = () => {
262
271
  clearTimeout(jumpTimer)
263
272
  }
264
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
+
265
294
  /** 列表末尾的工作球:生成中 / 收尾停留 / 跳回过渡 */
266
295
  const showWorking = computed(() => !!(props.running || finishing.value || workingLeaving.value))
267
296
  /** 输入框上方的空闲欢迎:含工作开始时的缩小离开帧 */
@@ -308,6 +337,13 @@ watch(
308
337
  if (running) {
309
338
  // 工作开始:立即从输入框上方跳到列表,无停留
310
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)
311
347
  const idleWasVisible = !prev && !finishing.value && !workingLeaving.value
312
348
  finishing.value = false
313
349
  workingLeaving.value = false
@@ -324,6 +360,15 @@ watch(
324
360
  }
325
361
  if (!prev) return
326
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
+
327
372
  // 清除会话后列表已空:跳过结束停留,立刻回到欢迎区
328
373
  if (visibleMessages.value.length === 0) {
329
374
  finishing.value = false
@@ -363,6 +408,7 @@ watch(
363
408
  (len) => {
364
409
  if (len > 0) return
365
410
  clearJumpTimers()
411
+ turnDurations.value = new Map()
366
412
  finishing.value = false
367
413
  workingLeaving.value = false
368
414
  idleLeaving.value = false
@@ -383,6 +429,7 @@ watch(
383
429
  onBeforeUnmount(() => {
384
430
  stopWelcomeRotation()
385
431
  clearJumpTimers()
432
+ stopTick()
386
433
  })
387
434
 
388
435
  const scrollRef = shallowRef<{ scrollTo: (position: ScrollPosition) => void }>()