@veltra/ai 2.1.2 → 2.1.4
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/index.d.ts +2 -2
- package/dist/chat/transports/openai.js +52 -6
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +31 -1
- package/dist/chat/use-chat.d.ts +31 -1
- package/dist/chat/use-chat.js +33 -1
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +17 -4
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +75 -20
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +1 -6
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +15 -0
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/style.d.ts +1 -0
- package/dist/components/ai-chat/style.js +1 -0
- package/dist/components/ai-chat/style2.css +38 -11
- package/dist/index.d.ts +2 -2
- package/dist/types/ai-chat.d.ts +11 -2
- package/package.json +5 -5
- package/src/chat/__test__/use-chat.test.ts +67 -0
- package/src/chat/transports/__test__/openai.test.ts +140 -0
- package/src/chat/transports/openai.ts +73 -7
- package/src/chat/types.ts +31 -0
- package/src/chat/use-chat.ts +39 -1
- package/src/components/ai-chat/__test__/ai-chat.test.ts +121 -1
- package/src/components/ai-chat/ai-chat.vue +13 -0
- package/src/components/ai-chat/chat-input.vue +64 -3
- package/src/components/ai-chat/message-item.vue +0 -1
- package/src/components/ai-chat/message-list.vue +22 -0
- package/src/components/ai-chat/style.scss +50 -15
- package/src/components/ai-chat/style.ts +1 -0
- package/src/types/ai-chat.ts +11 -1
|
@@ -375,6 +375,8 @@ describe('useChat', () => {
|
|
|
375
375
|
|
|
376
376
|
expect(chat.messages.value).toHaveLength(0)
|
|
377
377
|
expect(chat.queue.value).toHaveLength(0)
|
|
378
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
379
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
378
380
|
})
|
|
379
381
|
|
|
380
382
|
it('请求携带 model 与 reasoningLevel', async () => {
|
|
@@ -567,4 +569,69 @@ describe('useChat', () => {
|
|
|
567
569
|
// 最终同步给父级的快照也包含 assistant 消息
|
|
568
570
|
expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
|
|
569
571
|
})
|
|
572
|
+
|
|
573
|
+
it('累计 onUsage,无 usage 时保持 null;clear 一并重置', async () => {
|
|
574
|
+
const emit = createEmit()
|
|
575
|
+
const requests: ChatTransportRequest[] = []
|
|
576
|
+
const transport: ChatTransport = (req, handlers) => {
|
|
577
|
+
requests.push(req)
|
|
578
|
+
if (requests.length === 1) {
|
|
579
|
+
handlers.onToolCall?.({ id: 'call-1', name: 'add', arguments: '{"a":1,"b":2}' })
|
|
580
|
+
handlers.onUsage?.({
|
|
581
|
+
promptTokens: 10,
|
|
582
|
+
completionTokens: 4,
|
|
583
|
+
totalTokens: 14,
|
|
584
|
+
cacheHitTokens: 6,
|
|
585
|
+
cacheMissTokens: 4
|
|
586
|
+
})
|
|
587
|
+
} else {
|
|
588
|
+
handlers.onTextDelta('3')
|
|
589
|
+
handlers.onUsage?.({
|
|
590
|
+
promptTokens: 20,
|
|
591
|
+
completionTokens: 2,
|
|
592
|
+
totalTokens: 22,
|
|
593
|
+
cacheHitTokens: 12,
|
|
594
|
+
cacheMissTokens: 8
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const tools: ChatTool[] = [
|
|
599
|
+
{ name: 'add', description: '加法', parameters: {}, execute: () => 3 }
|
|
600
|
+
]
|
|
601
|
+
const chat = useChat({ props: { transport, tools }, emit })
|
|
602
|
+
|
|
603
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
604
|
+
chat.send('算一下')
|
|
605
|
+
await waitFinish(emit)
|
|
606
|
+
|
|
607
|
+
expect(chat.lastTurnUsage.value).toEqual({
|
|
608
|
+
promptTokens: 30,
|
|
609
|
+
completionTokens: 6,
|
|
610
|
+
totalTokens: 36,
|
|
611
|
+
cacheHitTokens: 18,
|
|
612
|
+
cacheMissTokens: 12
|
|
613
|
+
})
|
|
614
|
+
expect(chat.tokenUsage.value).toEqual(chat.lastTurnUsage.value)
|
|
615
|
+
|
|
616
|
+
chat.send('再算')
|
|
617
|
+
await vi.waitFor(() => {
|
|
618
|
+
expect(emit.mock.calls.filter((call) => call[0] === 'finish')).toHaveLength(2)
|
|
619
|
+
})
|
|
620
|
+
expect(chat.lastTurnUsage.value?.totalTokens).toBe(22)
|
|
621
|
+
expect(chat.tokenUsage.value?.totalTokens).toBe(58)
|
|
622
|
+
expect(chat.tokenUsage.value?.cacheHitTokens).toBe(30)
|
|
623
|
+
|
|
624
|
+
chat.clear()
|
|
625
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
626
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('transport 不回调 onUsage 时不编造数字', async () => {
|
|
630
|
+
const emit = createEmit()
|
|
631
|
+
const chat = useChat({ props: { transport: textTransport('ok') }, emit })
|
|
632
|
+
chat.send('hi')
|
|
633
|
+
await waitFinish(emit)
|
|
634
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
635
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
636
|
+
})
|
|
570
637
|
})
|
|
@@ -7,6 +7,7 @@ const baseHandlers = (): ChatTransportHandlers => ({
|
|
|
7
7
|
onTextDelta: vi.fn(),
|
|
8
8
|
onReasoningDelta: vi.fn(),
|
|
9
9
|
onToolCall: vi.fn(),
|
|
10
|
+
onUsage: vi.fn(),
|
|
10
11
|
onError: vi.fn()
|
|
11
12
|
})
|
|
12
13
|
|
|
@@ -314,4 +315,143 @@ data: [DONE]
|
|
|
314
315
|
arguments: '{"y":2}'
|
|
315
316
|
})
|
|
316
317
|
})
|
|
318
|
+
|
|
319
|
+
it('请求体带 stream_options.include_usage', async () => {
|
|
320
|
+
const fetchMock = vi.fn(async () => {
|
|
321
|
+
return new Response('data: [DONE]\n\n', {
|
|
322
|
+
status: 200,
|
|
323
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
327
|
+
|
|
328
|
+
const transport = createOpenAITransport({
|
|
329
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
330
|
+
})
|
|
331
|
+
await transport(
|
|
332
|
+
{
|
|
333
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
334
|
+
signal: new AbortController().signal
|
|
335
|
+
},
|
|
336
|
+
baseHandlers()
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
|
340
|
+
stream: true,
|
|
341
|
+
stream_options: { include_usage: true }
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('解析末包 usage,含缓存命中 / 未命中,无 usage 不回调', async () => {
|
|
346
|
+
const sse = String.raw`data: {"choices":[{"delta":{"content":"hi"}}]}
|
|
347
|
+
|
|
348
|
+
data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":8},"prompt_cache_hit_tokens":8,"prompt_cache_miss_tokens":4}}
|
|
349
|
+
|
|
350
|
+
data: [DONE]
|
|
351
|
+
`
|
|
352
|
+
vi.stubGlobal(
|
|
353
|
+
'fetch',
|
|
354
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const transport = createOpenAITransport({
|
|
358
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
359
|
+
})
|
|
360
|
+
const handlers = baseHandlers()
|
|
361
|
+
await transport(
|
|
362
|
+
{
|
|
363
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
364
|
+
signal: new AbortController().signal
|
|
365
|
+
},
|
|
366
|
+
handlers
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
expect(handlers.onTextDelta).toHaveBeenCalledWith('hi')
|
|
370
|
+
expect(handlers.onUsage).toHaveBeenCalledTimes(1)
|
|
371
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
372
|
+
promptTokens: 12,
|
|
373
|
+
completionTokens: 3,
|
|
374
|
+
totalTokens: 15,
|
|
375
|
+
cacheHitTokens: 8,
|
|
376
|
+
cacheMissTokens: 4
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('仅有 cached_tokens 时由 prompt − 命中得到未命中;缺 usage 不编造', async () => {
|
|
381
|
+
const withCache = String.raw`data: {"choices":[{"delta":{"content":"a"}}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11,"prompt_tokens_details":{"cached_tokens":7}}}
|
|
382
|
+
|
|
383
|
+
data: [DONE]
|
|
384
|
+
`
|
|
385
|
+
const noUsage = String.raw`data: {"choices":[{"delta":{"content":"b"}}]}
|
|
386
|
+
|
|
387
|
+
data: [DONE]
|
|
388
|
+
`
|
|
389
|
+
const fetchMock = vi
|
|
390
|
+
.fn()
|
|
391
|
+
.mockResolvedValueOnce(new Response(withCache, { status: 200 }))
|
|
392
|
+
.mockResolvedValueOnce(new Response(noUsage, { status: 200 }))
|
|
393
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
394
|
+
|
|
395
|
+
const transport = createOpenAITransport({
|
|
396
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const withCacheHandlers = baseHandlers()
|
|
400
|
+
await transport(
|
|
401
|
+
{
|
|
402
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
403
|
+
signal: new AbortController().signal
|
|
404
|
+
},
|
|
405
|
+
withCacheHandlers
|
|
406
|
+
)
|
|
407
|
+
expect(withCacheHandlers.onUsage).toHaveBeenCalledWith({
|
|
408
|
+
promptTokens: 10,
|
|
409
|
+
completionTokens: 1,
|
|
410
|
+
totalTokens: 11,
|
|
411
|
+
cacheHitTokens: 7,
|
|
412
|
+
cacheMissTokens: 3
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const noUsageHandlers = baseHandlers()
|
|
416
|
+
await transport(
|
|
417
|
+
{
|
|
418
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
419
|
+
signal: new AbortController().signal
|
|
420
|
+
},
|
|
421
|
+
noUsageHandlers
|
|
422
|
+
)
|
|
423
|
+
expect(noUsageHandlers.onUsage).not.toHaveBeenCalled()
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('解析 Anthropic 风格 cache_read / cache_creation', async () => {
|
|
427
|
+
const sse = String.raw`data: {"choices":[{"delta":{"content":"ok"}}],"usage":{"prompt_tokens":20,"completion_tokens":2,"total_tokens":22,"cache_read_input_tokens":15,"cache_creation_input_tokens":3}}
|
|
428
|
+
|
|
429
|
+
data: [DONE]
|
|
430
|
+
`
|
|
431
|
+
vi.stubGlobal(
|
|
432
|
+
'fetch',
|
|
433
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
const transport = createOpenAITransport({
|
|
437
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
438
|
+
})
|
|
439
|
+
const handlers = baseHandlers()
|
|
440
|
+
await transport(
|
|
441
|
+
{
|
|
442
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
443
|
+
signal: new AbortController().signal
|
|
444
|
+
},
|
|
445
|
+
handlers
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
449
|
+
promptTokens: 20,
|
|
450
|
+
completionTokens: 2,
|
|
451
|
+
totalTokens: 22,
|
|
452
|
+
cacheHitTokens: 15,
|
|
453
|
+
cacheMissTokens: 5,
|
|
454
|
+
cacheCreationTokens: 3
|
|
455
|
+
})
|
|
456
|
+
})
|
|
317
457
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChatModelOption, ChatProvider } from '../../providers'
|
|
2
2
|
import type {
|
|
3
3
|
ChatMessage,
|
|
4
|
+
ChatTokenUsage,
|
|
4
5
|
ChatTool,
|
|
5
6
|
ChatTransport,
|
|
6
7
|
ChatTransportHandlers,
|
|
@@ -82,6 +83,58 @@ interface AccumulatedToolCall {
|
|
|
82
83
|
arguments: string
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
|
|
91
|
+
function readCount(value: unknown): number | undefined {
|
|
92
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
|
|
93
|
+
return Math.floor(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从 OpenAI 兼容 usage 对象解析。
|
|
98
|
+
* 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
|
|
99
|
+
*/
|
|
100
|
+
function parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {
|
|
101
|
+
if (!isRecord(raw)) return null
|
|
102
|
+
|
|
103
|
+
const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
|
|
104
|
+
const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
|
|
105
|
+
if (promptTokens == null && completionTokens == null) return null
|
|
106
|
+
|
|
107
|
+
const prompt = promptTokens ?? 0
|
|
108
|
+
const completion = completionTokens ?? 0
|
|
109
|
+
const usage: ChatTokenUsage = {
|
|
110
|
+
promptTokens: prompt,
|
|
111
|
+
completionTokens: completion,
|
|
112
|
+
totalTokens: readCount(raw.total_tokens) ?? prompt + completion
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
|
|
116
|
+
const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
|
|
117
|
+
const cacheHitTokens =
|
|
118
|
+
readCount(details?.cached_tokens) ??
|
|
119
|
+
readCount(inputDetails?.cached_tokens) ??
|
|
120
|
+
readCount(raw.prompt_cache_hit_tokens) ??
|
|
121
|
+
readCount(raw.cache_read_input_tokens) ??
|
|
122
|
+
readCount(raw.cached_tokens)
|
|
123
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
124
|
+
|
|
125
|
+
const cacheMissTokens =
|
|
126
|
+
readCount(raw.prompt_cache_miss_tokens) ??
|
|
127
|
+
(cacheHitTokens != null && promptTokens != null
|
|
128
|
+
? Math.max(0, promptTokens - cacheHitTokens)
|
|
129
|
+
: undefined)
|
|
130
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
131
|
+
|
|
132
|
+
const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
|
|
133
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
134
|
+
|
|
135
|
+
return usage
|
|
136
|
+
}
|
|
137
|
+
|
|
85
138
|
/**
|
|
86
139
|
* 逐行解析 SSE 流。
|
|
87
140
|
* 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
|
|
@@ -112,15 +165,21 @@ async function parseSSE(
|
|
|
112
165
|
const handleData = (data: string) => {
|
|
113
166
|
if (data === '[DONE]') return
|
|
114
167
|
|
|
115
|
-
let chunk:
|
|
168
|
+
let chunk: unknown
|
|
116
169
|
try {
|
|
117
170
|
chunk = JSON.parse(data)
|
|
118
171
|
} catch {
|
|
119
172
|
return
|
|
120
173
|
}
|
|
174
|
+
if (!isRecord(chunk)) return
|
|
175
|
+
|
|
176
|
+
const usage = parseOpenAIUsage(chunk.usage)
|
|
177
|
+
if (usage) handlers.onUsage?.(usage)
|
|
121
178
|
|
|
122
|
-
const
|
|
123
|
-
|
|
179
|
+
const choices = chunk.choices
|
|
180
|
+
const first = Array.isArray(choices) ? choices[0] : undefined
|
|
181
|
+
const delta = isRecord(first) ? first.delta : undefined
|
|
182
|
+
if (!isRecord(delta)) return
|
|
124
183
|
|
|
125
184
|
if (typeof delta.content === 'string' && delta.content) {
|
|
126
185
|
handlers.onTextDelta(delta.content)
|
|
@@ -134,7 +193,8 @@ async function parseSSE(
|
|
|
134
193
|
|
|
135
194
|
if (Array.isArray(delta.tool_calls)) {
|
|
136
195
|
for (const tc of delta.tool_calls) {
|
|
137
|
-
|
|
196
|
+
if (!isRecord(tc)) continue
|
|
197
|
+
const index = typeof tc.index === 'number' ? tc.index : 0
|
|
138
198
|
// 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
|
|
139
199
|
if (index > lastIndex && accumulated.size > 0) flushToolCalls()
|
|
140
200
|
lastIndex = Math.max(lastIndex, index)
|
|
@@ -144,9 +204,10 @@ async function parseSSE(
|
|
|
144
204
|
call = { id: '', name: '', arguments: '' }
|
|
145
205
|
accumulated.set(index, call)
|
|
146
206
|
}
|
|
147
|
-
if (tc.id) call.id = tc.id
|
|
148
|
-
|
|
149
|
-
if (
|
|
207
|
+
if (typeof tc.id === 'string') call.id = tc.id
|
|
208
|
+
const fn = isRecord(tc.function) ? tc.function : undefined
|
|
209
|
+
if (typeof fn?.name === 'string') call.name += fn.name
|
|
210
|
+
if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
|
|
150
211
|
}
|
|
151
212
|
}
|
|
152
213
|
}
|
|
@@ -240,10 +301,15 @@ export function createOpenAITransport(options: OpenAITransportOptions): OpenAITr
|
|
|
240
301
|
return
|
|
241
302
|
}
|
|
242
303
|
|
|
304
|
+
const extraStreamOptions = isRecord(globalBody?.stream_options)
|
|
305
|
+
? globalBody.stream_options
|
|
306
|
+
: undefined
|
|
243
307
|
const requestBody: Record<string, unknown> = {
|
|
244
308
|
...globalBody,
|
|
245
309
|
model: modelId,
|
|
246
310
|
stream: true,
|
|
311
|
+
// 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
|
|
312
|
+
stream_options: { ...extraStreamOptions, include_usage: true },
|
|
247
313
|
messages: toOpenAIMessages(messages, systemPrompt),
|
|
248
314
|
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
249
315
|
}
|
package/src/chat/types.ts
CHANGED
|
@@ -151,6 +151,32 @@ export interface ChatTransportRequest {
|
|
|
151
151
|
signal: AbortSignal
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* 一次模型请求的 token 用量。
|
|
156
|
+
* 仅包含接口实际返回的字段;缓存类数字缺省为 undefined,不补 0。
|
|
157
|
+
*/
|
|
158
|
+
export interface ChatTokenUsage {
|
|
159
|
+
/** 输入 token */
|
|
160
|
+
promptTokens: number
|
|
161
|
+
/** 输出 token */
|
|
162
|
+
completionTokens: number
|
|
163
|
+
/** 合计 token */
|
|
164
|
+
totalTokens: number
|
|
165
|
+
/**
|
|
166
|
+
* 缓存命中。
|
|
167
|
+
* 来源:`prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
|
|
168
|
+
* `cache_read_input_tokens` / `cached_tokens`。
|
|
169
|
+
*/
|
|
170
|
+
cacheHitTokens?: number
|
|
171
|
+
/**
|
|
172
|
+
* 缓存未命中。
|
|
173
|
+
* 来源:`prompt_cache_miss_tokens`;若无该字段但有命中与 prompt,则为 prompt − 命中。
|
|
174
|
+
*/
|
|
175
|
+
cacheMissTokens?: number
|
|
176
|
+
/** 写入缓存(Anthropic 风格 `cache_creation_input_tokens`),有才有 */
|
|
177
|
+
cacheCreationTokens?: number
|
|
178
|
+
}
|
|
179
|
+
|
|
154
180
|
/** transport 流式事件回调 */
|
|
155
181
|
export interface ChatTransportHandlers {
|
|
156
182
|
/** 文本增量 */
|
|
@@ -162,6 +188,11 @@ export interface ChatTransportHandlers {
|
|
|
162
188
|
* 流式参数片段由 transport 内部累积,参数完整后才通过该回调抛出。
|
|
163
189
|
*/
|
|
164
190
|
onToolCall?(call: { id: string; name: string; arguments: string }): void
|
|
191
|
+
/**
|
|
192
|
+
* 本次请求的 token 用量(流式末包或非流式 usage)。
|
|
193
|
+
* 接口未返回 usage 时不要调用,不要填 0 充数。
|
|
194
|
+
*/
|
|
195
|
+
onUsage?(usage: ChatTokenUsage): void
|
|
165
196
|
/** 请求错误 */
|
|
166
197
|
onError?(error: Error): void
|
|
167
198
|
}
|
package/src/chat/use-chat.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ChatAttachment,
|
|
8
8
|
ChatMessage,
|
|
9
9
|
ChatQueuedMessage,
|
|
10
|
+
ChatTokenUsage,
|
|
10
11
|
ChatTool,
|
|
11
12
|
ChatToolCall
|
|
12
13
|
} from './types'
|
|
@@ -38,6 +39,27 @@ function resolveTools(userTools?: ChatTool[]): ChatTool[] {
|
|
|
38
39
|
return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
function mergeOptionalCount(a?: number, b?: number): number | undefined {
|
|
43
|
+
if (a == null && b == null) return undefined
|
|
44
|
+
return (a ?? 0) + (b ?? 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 累加两次 usage;缓存字段只在至少一侧有值时保留 */
|
|
48
|
+
function addTokenUsage(a: ChatTokenUsage, b: ChatTokenUsage): ChatTokenUsage {
|
|
49
|
+
const usage: ChatTokenUsage = {
|
|
50
|
+
promptTokens: a.promptTokens + b.promptTokens,
|
|
51
|
+
completionTokens: a.completionTokens + b.completionTokens,
|
|
52
|
+
totalTokens: a.totalTokens + b.totalTokens
|
|
53
|
+
}
|
|
54
|
+
const cacheHitTokens = mergeOptionalCount(a.cacheHitTokens, b.cacheHitTokens)
|
|
55
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
56
|
+
const cacheMissTokens = mergeOptionalCount(a.cacheMissTokens, b.cacheMissTokens)
|
|
57
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
58
|
+
const cacheCreationTokens = mergeOptionalCount(a.cacheCreationTokens, b.cacheCreationTokens)
|
|
59
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
60
|
+
return usage
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
/**
|
|
42
64
|
* AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
|
|
43
65
|
* 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
|
|
@@ -66,6 +88,11 @@ export function useChat(options: UseChatOptions) {
|
|
|
66
88
|
/** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */
|
|
67
89
|
const queue = ref<ChatQueuedMessage[]>([])
|
|
68
90
|
|
|
91
|
+
/** 当前会话累计 token;从未收到 usage 时为 null */
|
|
92
|
+
const tokenUsage = ref<ChatTokenUsage | null>(null)
|
|
93
|
+
/** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
|
|
94
|
+
const lastTurnUsage = ref<ChatTokenUsage | null>(null)
|
|
95
|
+
|
|
69
96
|
let abortController: AbortController | null = null
|
|
70
97
|
/** needsConfirm 工具的挂起确认器,key 为 toolCallId */
|
|
71
98
|
const confirmResolvers = new Map<string, (approved: boolean) => void>()
|
|
@@ -238,6 +265,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
238
265
|
|
|
239
266
|
running.value = true
|
|
240
267
|
finishedNaturally = false
|
|
268
|
+
lastTurnUsage.value = null
|
|
241
269
|
const controller = new AbortController()
|
|
242
270
|
abortController = controller
|
|
243
271
|
|
|
@@ -297,6 +325,12 @@ export function useChat(options: UseChatOptions) {
|
|
|
297
325
|
assistant.toolCalls!.push(toolCall)
|
|
298
326
|
emit('tool-call', toolCall)
|
|
299
327
|
},
|
|
328
|
+
onUsage: (usage) => {
|
|
329
|
+
lastTurnUsage.value = lastTurnUsage.value
|
|
330
|
+
? addTokenUsage(lastTurnUsage.value, usage)
|
|
331
|
+
: usage
|
|
332
|
+
tokenUsage.value = tokenUsage.value ? addTokenUsage(tokenUsage.value, usage) : usage
|
|
333
|
+
},
|
|
300
334
|
onError: (error) => {
|
|
301
335
|
requestError = error
|
|
302
336
|
}
|
|
@@ -388,11 +422,13 @@ export function useChat(options: UseChatOptions) {
|
|
|
388
422
|
void runConversation()
|
|
389
423
|
}
|
|
390
424
|
|
|
391
|
-
/**
|
|
425
|
+
/** 清空消息、待发送队列与 token 统计,生成中则先中断 */
|
|
392
426
|
const clear = () => {
|
|
393
427
|
abort()
|
|
394
428
|
queue.value = []
|
|
395
429
|
messages.value = []
|
|
430
|
+
tokenUsage.value = null
|
|
431
|
+
lastTurnUsage.value = null
|
|
396
432
|
snapshot()
|
|
397
433
|
}
|
|
398
434
|
|
|
@@ -402,6 +438,8 @@ export function useChat(options: UseChatOptions) {
|
|
|
402
438
|
reasoningLevel,
|
|
403
439
|
running,
|
|
404
440
|
queue,
|
|
441
|
+
tokenUsage,
|
|
442
|
+
lastTurnUsage,
|
|
405
443
|
send,
|
|
406
444
|
abort,
|
|
407
445
|
regenerate,
|
|
@@ -23,6 +23,7 @@ function mountAiChat(options: {
|
|
|
23
23
|
models?: ChatModelOption[]
|
|
24
24
|
model?: string
|
|
25
25
|
reasoningLevel?: string
|
|
26
|
+
tokenUsageDetail?: boolean
|
|
26
27
|
slots?: Record<string, (scope: any) => any>
|
|
27
28
|
}) {
|
|
28
29
|
const host = document.createElement('div')
|
|
@@ -41,7 +42,8 @@ function mountAiChat(options: {
|
|
|
41
42
|
welcome: options.welcome,
|
|
42
43
|
models: options.models,
|
|
43
44
|
model: options.model,
|
|
44
|
-
reasoningLevel: options.reasoningLevel
|
|
45
|
+
reasoningLevel: options.reasoningLevel,
|
|
46
|
+
tokenUsageDetail: options.tokenUsageDetail
|
|
45
47
|
},
|
|
46
48
|
options.slots
|
|
47
49
|
)
|
|
@@ -1131,4 +1133,122 @@ describe('UAiChat', () => {
|
|
|
1131
1133
|
vi.useRealTimers()
|
|
1132
1134
|
}
|
|
1133
1135
|
})
|
|
1136
|
+
|
|
1137
|
+
it('输入区提供清除入口;clear 清空后回到欢迎区', async () => {
|
|
1138
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
1139
|
+
handlers.onTextDelta('回复')
|
|
1140
|
+
}
|
|
1141
|
+
const { host, chat, unmount } = mountAiChat({ transport, welcome: '有什么可以帮你?' })
|
|
1142
|
+
|
|
1143
|
+
expect(host.querySelector('.u-ai-chat__input-clear')).toBeTruthy()
|
|
1144
|
+
expect(
|
|
1145
|
+
host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
|
|
1146
|
+
).toBe(true)
|
|
1147
|
+
expect(host.querySelector('.u-ai-chat__input-usage')).toBeFalsy()
|
|
1148
|
+
|
|
1149
|
+
chat.value?.send('你好')
|
|
1150
|
+
await vi.waitFor(() => {
|
|
1151
|
+
expect(host.querySelector('.md-stub')?.textContent).toContain('回复')
|
|
1152
|
+
})
|
|
1153
|
+
expect(
|
|
1154
|
+
host.querySelector('.u-ai-chat__input-clear-wrap')?.classList.contains('is-disabled')
|
|
1155
|
+
).toBe(false)
|
|
1156
|
+
|
|
1157
|
+
chat.value?.clear()
|
|
1158
|
+
await vi.waitFor(() => {
|
|
1159
|
+
expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
|
|
1160
|
+
})
|
|
1161
|
+
expect(host.querySelector('.md-stub')).toBeFalsy()
|
|
1162
|
+
unmount()
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
it('生成中 clear 中止请求并立即恢复欢迎区', async () => {
|
|
1166
|
+
const transport: ChatTransport = (req) => {
|
|
1167
|
+
return new Promise<void>((resolve) => {
|
|
1168
|
+
req.signal.addEventListener('abort', () => resolve(), { once: true })
|
|
1169
|
+
})
|
|
1170
|
+
}
|
|
1171
|
+
const { host, chat, unmount } = mountAiChat({ transport, welcome: '欢迎回来' })
|
|
1172
|
+
|
|
1173
|
+
chat.value?.send('hi')
|
|
1174
|
+
await nextTick()
|
|
1175
|
+
expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
|
|
1176
|
+
|
|
1177
|
+
chat.value?.clear()
|
|
1178
|
+
await vi.waitFor(() => {
|
|
1179
|
+
expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
|
|
1180
|
+
expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('欢迎回来')
|
|
1181
|
+
})
|
|
1182
|
+
unmount()
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
it('有 usage 时默认只显示总 token,tokenUsageDetail 追加缓存命中/未命中', async () => {
|
|
1186
|
+
const usageTransport: ChatTransport = (_req, handlers) => {
|
|
1187
|
+
handlers.onTextDelta('ok')
|
|
1188
|
+
handlers.onUsage?.({
|
|
1189
|
+
promptTokens: 10,
|
|
1190
|
+
completionTokens: 2,
|
|
1191
|
+
totalTokens: 12,
|
|
1192
|
+
cacheHitTokens: 4,
|
|
1193
|
+
cacheMissTokens: 6
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const compact = mountAiChat({ transport: usageTransport })
|
|
1198
|
+
compact.chat.value?.send('hi')
|
|
1199
|
+
await vi.waitFor(() => {
|
|
1200
|
+
expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
|
|
1201
|
+
})
|
|
1202
|
+
expect(compact.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain(
|
|
1203
|
+
'缓存命中'
|
|
1204
|
+
)
|
|
1205
|
+
compact.unmount()
|
|
1206
|
+
|
|
1207
|
+
const detail = mountAiChat({ transport: usageTransport, tokenUsageDetail: true })
|
|
1208
|
+
detail.chat.value?.send('hi')
|
|
1209
|
+
await vi.waitFor(() => {
|
|
1210
|
+
expect(detail.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
|
|
1211
|
+
'总 token 12 · 缓存命中 4 · 缓存未命中 6'
|
|
1212
|
+
)
|
|
1213
|
+
})
|
|
1214
|
+
const text = detail.host.querySelector('.u-ai-chat__input-usage')?.textContent ?? ''
|
|
1215
|
+
expect(text).not.toContain('本次')
|
|
1216
|
+
expect(text).not.toContain('累计')
|
|
1217
|
+
expect(text).not.toContain('输入')
|
|
1218
|
+
expect(text).not.toContain('输出')
|
|
1219
|
+
detail.unmount()
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
it('token 用量数字按 K/M 缩写,缓存字段缺失时不展示该项', async () => {
|
|
1223
|
+
const scaledTransport: ChatTransport = (_req, handlers) => {
|
|
1224
|
+
handlers.onTextDelta('ok')
|
|
1225
|
+
handlers.onUsage?.({
|
|
1226
|
+
promptTokens: 800,
|
|
1227
|
+
completionTokens: 700,
|
|
1228
|
+
totalTokens: 1500,
|
|
1229
|
+
cacheHitTokens: 1000,
|
|
1230
|
+
cacheMissTokens: 1_000_000
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
const scaled = mountAiChat({ transport: scaledTransport, tokenUsageDetail: true })
|
|
1234
|
+
scaled.chat.value?.send('hi')
|
|
1235
|
+
await vi.waitFor(() => {
|
|
1236
|
+
expect(scaled.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe(
|
|
1237
|
+
'总 token 1.5K · 缓存命中 1K · 缓存未命中 1M'
|
|
1238
|
+
)
|
|
1239
|
+
})
|
|
1240
|
+
scaled.unmount()
|
|
1241
|
+
|
|
1242
|
+
const noCacheTransport: ChatTransport = (_req, handlers) => {
|
|
1243
|
+
handlers.onTextDelta('ok')
|
|
1244
|
+
handlers.onUsage?.({ promptTokens: 10, completionTokens: 2, totalTokens: 12 })
|
|
1245
|
+
}
|
|
1246
|
+
const noCache = mountAiChat({ transport: noCacheTransport, tokenUsageDetail: true })
|
|
1247
|
+
noCache.chat.value?.send('hi')
|
|
1248
|
+
await vi.waitFor(() => {
|
|
1249
|
+
expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).toBe('总 token 12')
|
|
1250
|
+
})
|
|
1251
|
+
expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain('缓存')
|
|
1252
|
+
noCache.unmount()
|
|
1253
|
+
})
|
|
1134
1254
|
})
|
|
@@ -40,8 +40,12 @@
|
|
|
40
40
|
:placeholder="placeholder"
|
|
41
41
|
:accept="accept"
|
|
42
42
|
:max-attachment-size="maxAttachmentSize"
|
|
43
|
+
:clearable="clearable"
|
|
44
|
+
:token-usage="tokenUsage"
|
|
45
|
+
:token-usage-detail="tokenUsageDetail"
|
|
43
46
|
@send="handleSend"
|
|
44
47
|
@abort="abort"
|
|
48
|
+
@clear="clear"
|
|
45
49
|
/>
|
|
46
50
|
</div>
|
|
47
51
|
|
|
@@ -102,6 +106,8 @@ const {
|
|
|
102
106
|
reasoningLevel,
|
|
103
107
|
running,
|
|
104
108
|
queue,
|
|
109
|
+
tokenUsage,
|
|
110
|
+
lastTurnUsage,
|
|
105
111
|
send,
|
|
106
112
|
abort,
|
|
107
113
|
regenerate,
|
|
@@ -115,6 +121,11 @@ const {
|
|
|
115
121
|
/** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */
|
|
116
122
|
const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))
|
|
117
123
|
|
|
124
|
+
/** 有对话内容、队列或生成中才允许清除,避免误触空会话 */
|
|
125
|
+
const clearable = computed(
|
|
126
|
+
() => messages.value.length > 0 || queue.value.length > 0 || running.value
|
|
127
|
+
)
|
|
128
|
+
|
|
118
129
|
/** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
|
|
119
130
|
const panelCalls = computed(() => {
|
|
120
131
|
const calls: ChatToolCall[] = []
|
|
@@ -269,6 +280,8 @@ defineExpose<_AiChatExposed>({
|
|
|
269
280
|
abort,
|
|
270
281
|
regenerate,
|
|
271
282
|
clear,
|
|
283
|
+
tokenUsage,
|
|
284
|
+
lastTurnUsage,
|
|
272
285
|
queue,
|
|
273
286
|
startQueued,
|
|
274
287
|
removeQueued,
|