@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.
- 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 +160 -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 +32 -23
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +40 -5
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js +31 -95
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/reasoning-picker.js +68 -0
- package/dist/components/ai-chat/reasoning-picker.js.map +1 -0
- package/dist/components/ai-chat/style2.css +123 -68
- package/dist/providers/types.d.ts +2 -0
- package/dist/types/ai-chat.d.ts +2 -1
- package/package.json +1 -1
- package/src/chat/use-chat.ts +6 -0
- package/src/components/ai-chat/__test__/ai-chat.test.ts +79 -28
- package/src/components/ai-chat/ai-chat.vue +2 -0
- package/src/components/ai-chat/chat-input.vue +91 -16
- package/src/components/ai-chat/format-duration.ts +8 -0
- package/src/components/ai-chat/message-item.vue +6 -0
- package/src/components/ai-chat/message-list.vue +48 -1
- package/src/components/ai-chat/model-picker.vue +11 -110
- package/src/components/ai-chat/reasoning-picker.vue +70 -0
- package/src/components/ai-chat/style.scss +137 -72
- package/src/providers/types.ts +2 -0
- 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
|
|
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-
|
|
900
|
+
// 当前模型无 reasoningLevels,独立推理选择器不渲染
|
|
901
|
+
expect(host.querySelector('.u-ai-chat__reasoning-trigger')).toBeFalsy()
|
|
902
902
|
unmount()
|
|
903
903
|
})
|
|
904
904
|
|
|
905
|
-
it('切换到带 reasoningLevels
|
|
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-
|
|
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
|
|
949
|
-
'.u-ai-chat__input-toolbar-right .u-ai-
|
|
948
|
+
const trigger = host.querySelector<HTMLElement>(
|
|
949
|
+
'.u-ai-chat__input-toolbar-right .u-ai-chat__reasoning-trigger'
|
|
950
950
|
)
|
|
951
|
-
expect(
|
|
952
|
-
expect(
|
|
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-
|
|
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
|
-
|
|
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-
|
|
1319
|
+
expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1291
1320
|
})
|
|
1292
|
-
|
|
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-
|
|
1301
|
-
'总 token 12 · 缓存命中 4 · 缓存未命中 6'
|
|
1302
|
-
)
|
|
1331
|
+
expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1303
1332
|
})
|
|
1304
|
-
const
|
|
1305
|
-
expect(
|
|
1306
|
-
expect(
|
|
1307
|
-
expect(
|
|
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-
|
|
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-
|
|
1369
|
+
expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1340
1370
|
})
|
|
1341
|
-
|
|
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
|
-
<
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
163
|
-
const
|
|
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
|
|
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
|
-
|
|
244
|
+
rows.push({ label: '缓存命中', value: formatTokenCount(usage.cacheHitTokens) })
|
|
170
245
|
}
|
|
171
246
|
if (usage.cacheMissTokens != null) {
|
|
172
|
-
|
|
247
|
+
rows.push({ label: '缓存未命中', value: formatTokenCount(usage.cacheMissTokens) })
|
|
173
248
|
}
|
|
174
249
|
}
|
|
175
|
-
return
|
|
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
|
-
<
|
|
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 }>()
|