@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.
- package/dist/chat/use-chat.d.ts +15 -0
- package/dist/chat/use-chat.js +6 -0
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +3 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +155 -77
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/format-duration.js +13 -0
- package/dist/components/ai-chat/format-duration.js.map +1 -0
- package/dist/components/ai-chat/message-item.js +50 -31
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +43 -8
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/style.js +1 -0
- package/dist/components/ai-chat/style2.css +89 -18
- package/dist/components/ai-chat/tool-call.js +18 -11
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -2
- package/dist/providers/types.d.ts +2 -0
- package/dist/style.js +0 -1
- package/dist/tools/ask-question/ask-question.js +2 -1
- package/dist/tools/ask-question/ask-question.js.map +1 -1
- package/dist/types/ai-chat.d.ts +2 -1
- package/dist/types/index.d.ts +1 -2
- package/package.json +1 -1
- package/src/chat/use-chat.ts +6 -0
- package/src/components/ai-chat/__test__/ai-chat.test.ts +131 -24
- package/src/components/ai-chat/ai-chat.vue +2 -0
- package/src/components/ai-chat/chat-input.vue +81 -13
- package/src/components/ai-chat/format-duration.ts +8 -0
- package/src/components/ai-chat/message-item.vue +24 -16
- package/src/components/ai-chat/message-list.vue +56 -5
- package/src/components/ai-chat/style.scss +101 -18
- package/src/components/ai-chat/style.ts +2 -0
- package/src/components/ai-chat/tool-call.vue +27 -9
- package/src/components/index.ts +0 -1
- package/src/providers/types.ts +2 -0
- package/src/style.ts +0 -1
- package/src/tools/ask-question/__test__/ask-question.test.ts +20 -2
- package/src/tools/ask-question/ask-question.ts +2 -1
- package/src/types/ai-chat.ts +2 -1
- package/src/types/index.ts +0 -1
- package/dist/components/ai-orb/style.d.ts +0 -1
- package/dist/components/ai-orb/style.js +0 -1
- package/dist/types/ai-orb.d.ts +0 -26
- package/src/components/ai-orb/index.ts +0 -1
- package/src/components/ai-orb/style.ts +0 -1
- /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
|
|
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
|
-
//
|
|
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-
|
|
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
|
-
|
|
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-
|
|
1306
|
+
expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1227
1307
|
})
|
|
1228
|
-
|
|
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-
|
|
1237
|
-
'总 token 12 · 缓存命中 4 · 缓存未命中 6'
|
|
1238
|
-
)
|
|
1318
|
+
expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1239
1319
|
})
|
|
1240
|
-
const
|
|
1241
|
-
expect(
|
|
1242
|
-
expect(
|
|
1243
|
-
expect(
|
|
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-
|
|
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-
|
|
1356
|
+
expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1276
1357
|
})
|
|
1277
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
163
|
-
const
|
|
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
|
|
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
|
-
|
|
237
|
+
rows.push({ label: '缓存命中', value: formatTokenCount(usage.cacheHitTokens) })
|
|
170
238
|
}
|
|
171
239
|
if (usage.cacheMissTokens != null) {
|
|
172
|
-
|
|
240
|
+
rows.push({ label: '缓存未命中', value: formatTokenCount(usage.cacheMissTokens) })
|
|
173
241
|
}
|
|
174
242
|
}
|
|
175
|
-
return
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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>) => {
|