@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.
- 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 +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/style2.css +71 -11
- 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 +57 -19
- 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 +6 -0
- package/src/components/ai-chat/message-list.vue +48 -1
- package/src/components/ai-chat/style.scss +82 -10
- package/src/providers/types.ts +2 -0
- package/src/types/ai-chat.ts +2 -1
|
@@ -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 {
|
package/dist/types/ai-chat.d.ts
CHANGED
|
@@ -42,7 +42,8 @@ export interface AiChatProps {
|
|
|
42
42
|
rendererProps?: Record<string, unknown>;
|
|
43
43
|
/**
|
|
44
44
|
* 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
|
|
45
|
-
* 默认 false
|
|
45
|
+
* 默认 false:用量环明细面板只列 总 / 输入 / 输出;true 时追加有数据的缓存项。
|
|
46
|
+
* 接口未返回 usage 时不展示用量环。
|
|
46
47
|
*/
|
|
47
48
|
tokenUsageDetail?: boolean;
|
|
48
49
|
/**
|
package/package.json
CHANGED
package/src/chat/use-chat.ts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
1306
|
+
expect(compact.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1291
1307
|
})
|
|
1292
|
-
|
|
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-
|
|
1301
|
-
'总 token 12 · 缓存命中 4 · 缓存未命中 6'
|
|
1302
|
-
)
|
|
1318
|
+
expect(detail.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1303
1319
|
})
|
|
1304
|
-
const
|
|
1305
|
-
expect(
|
|
1306
|
-
expect(
|
|
1307
|
-
expect(
|
|
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-
|
|
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-
|
|
1356
|
+
expect(noCache.host.querySelector('.u-ai-chat__usage-trigger')).toBeTruthy()
|
|
1340
1357
|
})
|
|
1341
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|
|
@@ -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 }>()
|
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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;
|
package/src/providers/types.ts
CHANGED
|
@@ -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) */
|
package/src/types/ai-chat.ts
CHANGED
|
@@ -52,7 +52,8 @@ export interface AiChatProps {
|
|
|
52
52
|
rendererProps?: Record<string, unknown>
|
|
53
53
|
/**
|
|
54
54
|
* 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
|
|
55
|
-
* 默认 false
|
|
55
|
+
* 默认 false:用量环明细面板只列 总 / 输入 / 输出;true 时追加有数据的缓存项。
|
|
56
|
+
* 接口未返回 usage 时不展示用量环。
|
|
56
57
|
*/
|
|
57
58
|
tokenUsageDetail?: boolean
|
|
58
59
|
/**
|