@veltra/ai 2.1.10 → 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.
@@ -18,6 +18,8 @@ export interface ChatModel {
18
18
  reasoningLevels?: ChatReasoningLevel[];
19
19
  /** 默认推理等级(须落在 reasoningLevels 内) */
20
20
  defaultReasoningLevel?: string;
21
+ /** 上下文窗口上限(token)。配置后输入栏用量环按「最近一次请求总 token / 上限」显示占比 */
22
+ contextWindow?: number;
21
23
  }
22
24
  /** 模型服务商配置(内嵌 models) */
23
25
  export interface ChatProvider {
@@ -42,7 +42,8 @@ export interface AiChatProps {
42
42
  rendererProps?: Record<string, unknown>;
43
43
  /**
44
44
  * 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
45
- * 默认 false:仅在拿到 usage 时显示会话累计「总 token」。接口未返回 usage 时不展示。
45
+ * 默认 false:用量环明细面板只列 总 / 输入 / 输出;true 时追加有数据的缓存项。
46
+ * 接口未返回 usage 时不展示用量环。
46
47
  */
47
48
  tokenUsageDetail?: boolean;
48
49
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/ai",
3
- "version": "2.1.10",
3
+ "version": "2.1.11",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -60,6 +60,8 @@ export function useChat(options: UseChatOptions) {
60
60
  const tokenUsage = ref<ChatTokenUsage | null>(null)
61
61
  /** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
62
62
  const lastTurnUsage = ref<ChatTokenUsage | null>(null)
63
+ /** 最近一次单次请求的 token(未跨轮累计),其 totalTokens 近似当前上下文占用 */
64
+ const lastRequestUsage = ref<ChatTokenUsage | null>(null)
63
65
 
64
66
  /** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */
65
67
  const jobs = ref<ChatJob[]>([])
@@ -344,6 +346,7 @@ export function useChat(options: UseChatOptions) {
344
346
  emit('tool-call', toolCall)
345
347
  },
346
348
  onUsage: (usage) => {
349
+ lastRequestUsage.value = usage
347
350
  lastTurnUsage.value = lastTurnUsage.value
348
351
  ? addTokenUsage(lastTurnUsage.value, usage)
349
352
  : usage
@@ -462,6 +465,7 @@ export function useChat(options: UseChatOptions) {
462
465
  if (session()) {
463
466
  sessionRuntime.resetLocal()
464
467
  lastTurnUsage.value = null
468
+ lastRequestUsage.value = null
465
469
  return
466
470
  }
467
471
  abort()
@@ -469,6 +473,7 @@ export function useChat(options: UseChatOptions) {
469
473
  messages.value = []
470
474
  tokenUsage.value = null
471
475
  lastTurnUsage.value = null
476
+ lastRequestUsage.value = null
472
477
  snapshot()
473
478
  }
474
479
 
@@ -481,6 +486,7 @@ export function useChat(options: UseChatOptions) {
481
486
  jobs,
482
487
  tokenUsage,
483
488
  lastTurnUsage,
489
+ lastRequestUsage,
484
490
  projections,
485
491
  title,
486
492
  pendingApprovals,
@@ -1234,7 +1234,7 @@ describe('UAiChat', () => {
1234
1234
  expect(
1235
1235
  host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
1236
1236
  ).toBe(true)
1237
- expect(host.querySelector('.u-ai-chat__input-usage')).toBeFalsy()
1237
+ expect(host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1238
1238
 
1239
1239
  chat.value?.send('你好')
1240
1240
  await vi.waitFor(() => {
@@ -1272,7 +1272,21 @@ describe('UAiChat', () => {
1272
1272
  unmount()
1273
1273
  })
1274
1274
 
1275
- 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 () => {
1276
1290
  const usageTransport: ChatTransport = (_req, handlers) => {
1277
1291
  handlers.onTextDelta('ok')
1278
1292
  handlers.onUsage?.({
@@ -1285,27 +1299,28 @@ describe('UAiChat', () => {
1285
1299
  }
1286
1300
 
1287
1301
  const compact = mountAiChat({ transport: usageTransport })
1302
+ // 未收到 usage 时不渲染用量环
1303
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeFalsy()
1288
1304
  compact.chat.value?.send('hi')
1289
1305
  await vi.waitFor(() => {
1290
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1306
+ expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1291
1307
  })
1292
- expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain(
1293
- '缓存命中'
1294
- )
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()
1295
1313
  compact.unmount()
1296
1314
 
1297
1315
  const detail = mountAiChat({ transport: usageTransport, tokenUsageDetail: true })
1298
1316
  detail.chat.value?.send('hi')
1299
1317
  await vi.waitFor(() => {
1300
- expect(detail.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1301
- '总 token 12 · 缓存命中 4 · 缓存未命中 6'
1302
- )
1318
+ expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1303
1319
  })
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('输出')
1320
+ const detailRows = await openUsagePanel(detail.host)
1321
+ expect(detailRows['总 token']).toBe('12')
1322
+ expect(detailRows['缓存命中']).toBe('4')
1323
+ expect(detailRows['缓存未命中']).toBe('6')
1309
1324
  detail.unmount()
1310
1325
  })
1311
1326
 
@@ -1323,10 +1338,12 @@ describe('UAiChat', () => {
1323
1338
  const scaled = mountAiChat({ transport: scaledTransport, tokenUsageDetail: true })
1324
1339
  scaled.chat.value?.send('hi')
1325
1340
  await vi.waitFor(() => {
1326
- expect(scaled.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
1327
- '总 token 1.5K · 缓存命中 1K · 缓存未命中 1M'
1328
- )
1341
+ expect(scaled.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1329
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')
1330
1347
  scaled.unmount()
1331
1348
 
1332
1349
  const noCacheTransport: ChatTransport = (_req, handlers) => {
@@ -1336,12 +1353,33 @@ describe('UAiChat', () => {
1336
1353
  const noCache = mountAiChat({ transport: noCacheTransport, tokenUsageDetail: true })
1337
1354
  noCache.chat.value?.send('hi')
1338
1355
  await vi.waitFor(() => {
1339
- expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
1356
+ expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
1340
1357
  })
1341
- 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)
1342
1361
  noCache.unmount()
1343
1362
  })
1344
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
+
1345
1383
  const UNKNOWN_TOOL = 'totally-unknown-tool-xyz'
1346
1384
 
1347
1385
  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,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
+ }
@@ -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 }>()
@@ -178,12 +178,14 @@ $root-name: ai-chat;
178
178
  color: fn.use-var(text-color, assist);
179
179
  }
180
180
 
181
+ // 负边距吃掉主列横向 padding、顶到主列边缘:滚动条出现在容器右侧而非贴着会话列表;
182
+ // 800px 限宽下沉到 UScroll 内容层(__list-content)
181
183
  @include m.e(list-wrap) {
182
184
  position: relative;
183
185
  flex: 1;
184
186
  min-height: 0;
185
- width: 100%;
186
- max-width: 800px;
187
+ width: calc(100% + #{fn.use-var(gap, default)} * 2);
188
+ margin: 0 calc(#{fn.use-var(gap, default)} * -1);
187
189
  display: flex;
188
190
  flex-direction: column;
189
191
  // 纵向留出呼吸位:首条消息不贴容器顶,滚动时内容不顶边;横向由各消息自身 padding 控制
@@ -191,6 +193,16 @@ $root-name: ai-chat;
191
193
  box-sizing: border-box;
192
194
  }
193
195
 
196
+ // UScroll 内容层:左右 gap 补偿 list-wrap 负边距,宽屏内层 800px、窄屏与主列内容盒同宽,
197
+ // 消息区视觉几何与限宽在 list-wrap 时一致
198
+ @include m.e(list-content) {
199
+ width: 100%;
200
+ max-width: calc(800px + #{fn.use-var(gap, default)} * 2);
201
+ margin: 0 auto;
202
+ padding: 0 fn.use-var(gap, default);
203
+ box-sizing: border-box;
204
+ }
205
+
194
206
  @include m.e(list) {
195
207
  flex: 1;
196
208
  min-height: 0;
@@ -200,6 +212,8 @@ $root-name: ai-chat;
200
212
  @include m.e(welcome) {
201
213
  flex-shrink: 0;
202
214
  width: 100%;
215
+ max-width: 800px;
216
+ margin: 0 auto;
203
217
  height: 48px;
204
218
  @include m.flex(flex, flex-start, center);
205
219
  color: fn.use-var(text-color, assist);
@@ -303,11 +317,11 @@ $root-name: ai-chat;
303
317
  transform: translateY(-6px);
304
318
  }
305
319
 
306
- // 生成中的统一工作状态指示(列表末尾)
320
+ // 生成中的统一工作状态指示(列表末尾);横向定位由 __list-content 的 padding 提供
307
321
  @include m.e(working) {
308
322
  @include m.flex(flex, flex-start, center);
309
323
  gap: fn.use-var(gap, small);
310
- padding: 0 fn.use-var(gap, default) fn.use-var(gap, small);
324
+ padding: 0 0 fn.use-var(gap, small);
311
325
  width: fit-content;
312
326
 
313
327
  // 工作开始:立即从 0 放大(无延时),与输入框上方缩小形成对向跳
@@ -334,6 +348,13 @@ $root-name: ai-chat;
334
348
  font-size: fn.use-var(font-size-main, small);
335
349
  }
336
350
 
351
+ // 工作计时 / 收尾停留期间的总用时
352
+ @include m.e(working-time) {
353
+ font-size: fn.use-var(font-size-main, small);
354
+ color: fn.use-var(text-color, assist);
355
+ font-variant-numeric: tabular-nums;
356
+ }
357
+
337
358
  @media (prefers-reduced-motion: reduce) {
338
359
  .u-#{$root-name}__welcome.is-enter .u-ai-orb,
339
360
  .u-#{$root-name}__working .u-ai-orb {
@@ -480,6 +501,14 @@ $root-name: ai-chat;
480
501
  gap: fn.use-var(gap, small);
481
502
  }
482
503
 
504
+ // 答案操作区前的本轮总用时
505
+ @include m.e(message-duration) {
506
+ align-self: center;
507
+ color: fn.use-var(text-color, assist);
508
+ font-size: fn.use-var(font-size-main, small);
509
+ font-variant-numeric: tabular-nums;
510
+ }
511
+
483
512
  // 思考过程
484
513
  @include m.e(reasoning) {
485
514
  margin-bottom: 4px;
@@ -1151,12 +1180,32 @@ $root-name: ai-chat;
1151
1180
  }
1152
1181
  }
1153
1182
 
1154
- @include m.e(input-usage) {
1155
- min-width: 0;
1156
- @include m.ellipsis;
1157
- font-size: fn.use-var(font-size-main, small);
1158
- color: fn.use-var(text-color, assist);
1159
- line-height: 1.4;
1183
+ @include m.e(usage-trigger) {
1184
+ @include m.flex(inline-flex, center, center);
1185
+ flex-shrink: 0;
1186
+ padding: 2px;
1187
+ border: none;
1188
+ border-radius: 50%;
1189
+ background: transparent;
1190
+ }
1191
+
1192
+ @include m.e(usage-ring) {
1193
+ display: block;
1194
+ width: 14px;
1195
+ height: 14px;
1196
+ }
1197
+
1198
+ @include m.e(usage-ring-track) {
1199
+ fill: none;
1200
+ stroke: fn.use-var(border-color);
1201
+ stroke-width: 5;
1202
+ }
1203
+
1204
+ @include m.e(usage-ring-value) {
1205
+ fill: none;
1206
+ stroke: fn.use-var(color, primary);
1207
+ stroke-width: 5;
1208
+ transition: stroke-dasharray 0.3s ease;
1160
1209
  }
1161
1210
 
1162
1211
  @include m.e(input-stop) {
@@ -1201,6 +1250,29 @@ $root-name: ai-chat;
1201
1250
  }
1202
1251
  }
1203
1252
 
1253
+ // 用量明细面板经 teleport 挂载到 popper 容器,不处于 .u-ai-chat 内,需独立定义
1254
+ .u-#{$root-name}__usage-panel {
1255
+ padding: 6px 4px;
1256
+ user-select: none;
1257
+
1258
+ .u-#{$root-name}__usage-row {
1259
+ @include m.flex(flex, space-between, center);
1260
+ gap: 24px;
1261
+ padding: 3px 8px;
1262
+ font-size: fn.use-var(font-size-main, small);
1263
+ line-height: 1.6;
1264
+ }
1265
+
1266
+ .u-#{$root-name}__usage-label {
1267
+ color: fn.use-var(text-color, assist);
1268
+ }
1269
+
1270
+ .u-#{$root-name}__usage-value {
1271
+ color: fn.use-var(text-color, main);
1272
+ font-variant-numeric: tabular-nums;
1273
+ }
1274
+ }
1275
+
1204
1276
  // 模型选择面板经 teleport 挂载到 popper 容器,不处于 .u-ai-chat 内,需独立定义
1205
1277
  .u-#{$root-name}__model-panel {
1206
1278
  padding: 4px;
@@ -18,6 +18,8 @@ export interface ChatModel {
18
18
  reasoningLevels?: ChatReasoningLevel[]
19
19
  /** 默认推理等级(须落在 reasoningLevels 内) */
20
20
  defaultReasoningLevel?: string
21
+ /** 上下文窗口上限(token)。配置后输入栏用量环按「最近一次请求总 token / 上限」显示占比 */
22
+ contextWindow?: number
21
23
  }
22
24
 
23
25
  /** 模型服务商配置(内嵌 models) */
@@ -52,7 +52,8 @@ export interface AiChatProps {
52
52
  rendererProps?: Record<string, unknown>
53
53
  /**
54
54
  * 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
55
- * 默认 false:仅在拿到 usage 时显示会话累计「总 token」。接口未返回 usage 时不展示。
55
+ * 默认 false:用量环明细面板只列 总 / 输入 / 输出;true 时追加有数据的缓存项。
56
+ * 接口未返回 usage 时不展示用量环。
56
57
  */
57
58
  tokenUsageDetail?: boolean
58
59
  /**