@veltra/ai 2.1.5 → 2.1.7
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/README.md +42 -0
- package/dist/chat/fold.d.ts +41 -0
- package/dist/chat/fold.js +208 -0
- package/dist/chat/fold.js.map +1 -0
- package/dist/chat/index.d.ts +4 -2
- package/dist/chat/session.d.ts +123 -0
- package/dist/chat/session.js +79 -0
- package/dist/chat/session.js.map +1 -0
- package/dist/chat/types.d.ts +38 -1
- package/dist/chat/use-chat-helpers.js +39 -0
- package/dist/chat/use-chat-helpers.js.map +1 -0
- package/dist/chat/use-chat-session.js +68 -0
- package/dist/chat/use-chat-session.js.map +1 -0
- package/dist/chat/use-chat.d.ts +50 -2
- package/dist/chat/use-chat.js +73 -39
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +49 -14
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/approval-banner.js +54 -0
- package/dist/components/ai-chat/approval-banner.js.map +1 -0
- package/dist/components/ai-chat/ask-question.js +32 -51
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +9 -3
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/di.js.map +1 -1
- package/dist/components/ai-chat/job-bar.js +48 -0
- package/dist/components/ai-chat/job-bar.js.map +1 -0
- package/dist/components/ai-chat/message-list.js +5 -2
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js +9 -3
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js +12 -30
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/style2.css +150 -8
- package/dist/components/ai-chat/tool-call.js +51 -40
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/tool-icons.js +31 -0
- package/dist/components/ai-chat/tool-icons.js.map +1 -0
- package/dist/components/ai-chat/tool-json.js +40 -0
- package/dist/components/ai-chat/tool-json.js.map +1 -0
- package/dist/components/ai-orb/style2.css +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -1
- package/dist/tools/ask-question/ask-question.js +48 -2
- package/dist/tools/ask-question/ask-question.js.map +1 -1
- package/dist/types/ai-chat.d.ts +17 -6
- package/package.json +7 -7
- package/src/chat/__test__/fold.test.ts +136 -0
- package/src/chat/__test__/session.test.ts +191 -0
- package/src/chat/__test__/use-chat.test.ts +268 -1
- package/src/chat/fold.ts +260 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/session.ts +168 -0
- package/src/chat/types.ts +40 -0
- package/src/chat/use-chat-helpers.ts +39 -0
- package/src/chat/use-chat-session.ts +87 -0
- package/src/chat/use-chat.ts +90 -44
- package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
- package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
- package/src/components/ai-chat/ai-chat.vue +39 -4
- package/src/components/ai-chat/approval-banner.vue +37 -0
- package/src/components/ai-chat/ask-question.vue +38 -54
- package/src/components/ai-chat/chat-input.vue +10 -2
- package/src/components/ai-chat/di.ts +6 -4
- package/src/components/ai-chat/job-bar.vue +41 -0
- package/src/components/ai-chat/message-list.vue +4 -1
- package/src/components/ai-chat/queue-list.vue +3 -1
- package/src/components/ai-chat/side-panel.vue +15 -13
- package/src/components/ai-chat/style.scss +162 -6
- package/src/components/ai-chat/tool-call.vue +46 -34
- package/src/components/ai-chat/tool-icons.ts +33 -0
- package/src/components/ai-chat/tool-json.vue +52 -0
- package/src/tools/ask-question/ask-question.ts +53 -4
- package/src/types/ai-chat.ts +17 -5
package/src/chat/use-chat.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { useModel } from '@veltra/compositions'
|
|
2
2
|
import { reactive, ref, watch, type Ref } from 'vue'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { AskQuestionItem } from '../tools'
|
|
5
5
|
import type { AiChatEmits, AiChatProps } from '../types'
|
|
6
|
+
import type { ChatPendingApproval } from './fold'
|
|
7
|
+
import { isServerTransport, type ChatSessionTransport } from './session'
|
|
6
8
|
import type {
|
|
7
9
|
ChatAttachment,
|
|
10
|
+
ChatJob,
|
|
8
11
|
ChatMessage,
|
|
9
12
|
ChatQueuedMessage,
|
|
10
13
|
ChatTokenUsage,
|
|
11
14
|
ChatTool,
|
|
12
15
|
ChatToolCall
|
|
13
16
|
} from './types'
|
|
17
|
+
import { addTokenUsage, resolveTools, serializeResult } from './use-chat-helpers'
|
|
18
|
+
import { createSessionRuntime } from './use-chat-session'
|
|
14
19
|
|
|
15
20
|
export interface UseChatOptions {
|
|
16
21
|
props: AiChatProps
|
|
@@ -23,47 +28,10 @@ const uid = () => {
|
|
|
23
28
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
function serializeResult(result: unknown): string {
|
|
27
|
-
if (typeof result === 'string') return result
|
|
28
|
-
try {
|
|
29
|
-
return JSON.stringify(result) ?? 'null'
|
|
30
|
-
} catch {
|
|
31
|
-
return String(result)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 内置工具 + 用户工具,同名时内置优先 */
|
|
36
|
-
function resolveTools(userTools?: ChatTool[]): ChatTool[] {
|
|
37
|
-
const builtins = createBuiltinTools()
|
|
38
|
-
const names = new Set(builtins.map((t) => t.name))
|
|
39
|
-
return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
|
|
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
|
-
|
|
63
31
|
/**
|
|
64
32
|
* AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
|
|
65
33
|
* 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
|
|
66
|
-
*
|
|
34
|
+
* 函数型 transport 注入内置工具;session transport 不注入、不执行客户端工具。
|
|
67
35
|
*/
|
|
68
36
|
export function useChat(options: UseChatOptions) {
|
|
69
37
|
const { props, emit } = options
|
|
@@ -93,6 +61,28 @@ export function useChat(options: UseChatOptions) {
|
|
|
93
61
|
/** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
|
|
94
62
|
const lastTurnUsage = ref<ChatTokenUsage | null>(null)
|
|
95
63
|
|
|
64
|
+
/** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */
|
|
65
|
+
const jobs = ref<ChatJob[]>([])
|
|
66
|
+
const projections = ref<Record<string, unknown>>({})
|
|
67
|
+
const title = ref<string | null>(null)
|
|
68
|
+
const pendingApprovals = ref<ChatPendingApproval[]>([])
|
|
69
|
+
const pendingQuestion = ref<{ questions: AskQuestionItem[]; rpcId: string } | null>(null)
|
|
70
|
+
|
|
71
|
+
const sessionRuntime = createSessionRuntime({
|
|
72
|
+
messages,
|
|
73
|
+
queue,
|
|
74
|
+
jobs,
|
|
75
|
+
tokenUsage,
|
|
76
|
+
projections,
|
|
77
|
+
title,
|
|
78
|
+
running,
|
|
79
|
+
pendingApprovals,
|
|
80
|
+
pendingQuestion
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const session = (): ChatSessionTransport | null =>
|
|
84
|
+
isServerTransport(props.transport) ? props.transport : null
|
|
85
|
+
|
|
96
86
|
let abortController: AbortController | null = null
|
|
97
87
|
/** needsConfirm 工具的挂起确认器,key 为 toolCallId */
|
|
98
88
|
const confirmResolvers = new Map<string, (approved: boolean) => void>()
|
|
@@ -129,6 +119,25 @@ export function useChat(options: UseChatOptions) {
|
|
|
129
119
|
{ immediate: true }
|
|
130
120
|
)
|
|
131
121
|
|
|
122
|
+
// session:open + fetchHistory 播种;更换或卸载时 disposer
|
|
123
|
+
watch(
|
|
124
|
+
() => props.transport,
|
|
125
|
+
(transport, _prev, onCleanup) => {
|
|
126
|
+
if (!isServerTransport(transport)) return
|
|
127
|
+
sessionRuntime.attach(transport, onCleanup)
|
|
128
|
+
},
|
|
129
|
+
{ immediate: true }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// session 且已 open 时,模型切换走 selectModel
|
|
133
|
+
watch(model, (modelId) => {
|
|
134
|
+
const current = session()
|
|
135
|
+
if (!sessionRuntime.opened || !current || !modelId) return
|
|
136
|
+
const option = props.models?.find((m) => m.id === modelId)
|
|
137
|
+
if (!option) return
|
|
138
|
+
void current.selectModel(option.providerId, modelId)
|
|
139
|
+
})
|
|
140
|
+
|
|
132
141
|
/**
|
|
133
142
|
* 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
|
|
134
143
|
* 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
|
|
@@ -146,6 +155,11 @@ export function useChat(options: UseChatOptions) {
|
|
|
146
155
|
*/
|
|
147
156
|
const send = (content: string, attachments?: ChatAttachment[]) => {
|
|
148
157
|
if (!content.trim() && !attachments?.length) return
|
|
158
|
+
const current = session()
|
|
159
|
+
if (current) {
|
|
160
|
+
void current.send(content, attachments)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
149
163
|
enqueue(content, attachments)
|
|
150
164
|
}
|
|
151
165
|
|
|
@@ -159,6 +173,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
159
173
|
beforeId?: string
|
|
160
174
|
): ChatQueuedMessage => {
|
|
161
175
|
const item: ChatQueuedMessage = { id: uid(), content, attachments }
|
|
176
|
+
if (session()) return item
|
|
162
177
|
const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1
|
|
163
178
|
if (anchorIndex === -1) {
|
|
164
179
|
queue.value.push(item)
|
|
@@ -177,6 +192,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
177
192
|
|
|
178
193
|
/** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */
|
|
179
194
|
const startQueued = (id: string) => {
|
|
195
|
+
if (session()) return
|
|
180
196
|
const item = removeQueued(id)
|
|
181
197
|
if (!item) return
|
|
182
198
|
|
|
@@ -193,6 +209,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
193
209
|
|
|
194
210
|
/** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */
|
|
195
211
|
const removeQueued = (id: string): ChatQueuedMessage | undefined => {
|
|
212
|
+
if (session()) return undefined
|
|
196
213
|
const index = queue.value.findIndex((item) => item.id === id)
|
|
197
214
|
if (index === -1) return undefined
|
|
198
215
|
const [item] = queue.value.splice(index, 1)
|
|
@@ -287,9 +304,10 @@ export function useChat(options: UseChatOptions) {
|
|
|
287
304
|
|
|
288
305
|
/** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */
|
|
289
306
|
const runRound = async (controller: AbortController, depth: number): Promise<void> => {
|
|
290
|
-
|
|
307
|
+
const transport = props.transport
|
|
308
|
+
if (typeof transport !== 'function') return
|
|
291
309
|
const { signal } = controller
|
|
292
|
-
const tools = resolveTools(props.tools)
|
|
310
|
+
const tools = resolveTools(props.tools as ChatTool[] | undefined)
|
|
293
311
|
|
|
294
312
|
const assistant = reactive<ChatMessage>({
|
|
295
313
|
id: uid(),
|
|
@@ -303,7 +321,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
303
321
|
let requestError: Error | null = null
|
|
304
322
|
|
|
305
323
|
try {
|
|
306
|
-
await
|
|
324
|
+
await transport(
|
|
307
325
|
{
|
|
308
326
|
// 排除刚追加的 assistant 占位消息
|
|
309
327
|
messages: messages.value.slice(0, -1),
|
|
@@ -395,17 +413,34 @@ export function useChat(options: UseChatOptions) {
|
|
|
395
413
|
|
|
396
414
|
/** 中断当前生成,挂起的工具确认按拒绝处理 */
|
|
397
415
|
const abort = () => {
|
|
416
|
+
const current = session()
|
|
417
|
+
if (current) {
|
|
418
|
+
void current.cancel()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
398
421
|
abortController?.abort()
|
|
399
422
|
}
|
|
400
423
|
|
|
401
|
-
/** 响应 needsConfirm
|
|
424
|
+
/** 响应 needsConfirm 工具的用户确认;session 下按 callId 找到 rpcId 后 respond */
|
|
402
425
|
const respondToolCall = (toolCallId: string, approved: boolean) => {
|
|
426
|
+
const current = session()
|
|
427
|
+
if (current) {
|
|
428
|
+
const pending = pendingApprovals.value.find((a) => a.callId === toolCallId)
|
|
429
|
+
if (pending) void current.respond(pending.rpcId, approved)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
403
432
|
confirmResolvers.get(toolCallId)?.(approved)
|
|
404
433
|
}
|
|
405
434
|
|
|
435
|
+
/** session 审批横幅 / 提问提交:直接 respond(rpcId) */
|
|
436
|
+
const respondSession = (rpcId: string, ok: boolean, value?: unknown) => {
|
|
437
|
+
const current = session()
|
|
438
|
+
if (current) void current.respond(rpcId, ok, value)
|
|
439
|
+
}
|
|
440
|
+
|
|
406
441
|
/** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */
|
|
407
442
|
const regenerate = () => {
|
|
408
|
-
if (running.value) return
|
|
443
|
+
if (session() || running.value) return
|
|
409
444
|
|
|
410
445
|
const list = messages.value
|
|
411
446
|
let lastUserIndex = -1
|
|
@@ -424,6 +459,11 @@ export function useChat(options: UseChatOptions) {
|
|
|
424
459
|
|
|
425
460
|
/** 清空消息、待发送队列与 token 统计,生成中则先中断 */
|
|
426
461
|
const clear = () => {
|
|
462
|
+
if (session()) {
|
|
463
|
+
sessionRuntime.resetLocal()
|
|
464
|
+
lastTurnUsage.value = null
|
|
465
|
+
return
|
|
466
|
+
}
|
|
427
467
|
abort()
|
|
428
468
|
queue.value = []
|
|
429
469
|
messages.value = []
|
|
@@ -438,13 +478,19 @@ export function useChat(options: UseChatOptions) {
|
|
|
438
478
|
reasoningLevel,
|
|
439
479
|
running,
|
|
440
480
|
queue,
|
|
481
|
+
jobs,
|
|
441
482
|
tokenUsage,
|
|
442
483
|
lastTurnUsage,
|
|
484
|
+
projections,
|
|
485
|
+
title,
|
|
486
|
+
pendingApprovals,
|
|
487
|
+
pendingQuestion,
|
|
443
488
|
send,
|
|
444
489
|
abort,
|
|
445
490
|
regenerate,
|
|
446
491
|
clear,
|
|
447
492
|
respondToolCall,
|
|
493
|
+
respondSession,
|
|
448
494
|
enqueue,
|
|
449
495
|
startQueued,
|
|
450
496
|
removeQueued
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
createApp,
|
|
4
|
+
defineComponent,
|
|
5
|
+
h,
|
|
6
|
+
nextTick,
|
|
7
|
+
reactive,
|
|
8
|
+
ref,
|
|
9
|
+
type Component,
|
|
10
|
+
type PropType
|
|
11
|
+
} from 'vue'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createServerTransport,
|
|
15
|
+
type ChatSessionAdapter,
|
|
16
|
+
type ChatSessionEvent
|
|
17
|
+
} from '../../../chat/session'
|
|
18
|
+
import type {
|
|
19
|
+
ChatMessage,
|
|
20
|
+
ChatTool,
|
|
21
|
+
ChatToolCall,
|
|
22
|
+
ChatToolMeta,
|
|
23
|
+
ChatTransport
|
|
24
|
+
} from '../../../chat/types'
|
|
5
25
|
import type { ChatModelOption } from '../../../providers'
|
|
6
|
-
import type { AiChatExposed } from '../../../types'
|
|
26
|
+
import type { AiChatExposed, AiChatProps } from '../../../types'
|
|
7
27
|
import UAiChat from '../ai-chat.vue'
|
|
8
28
|
|
|
9
29
|
// happy-dom 环境下用桩组件替换 MarkdownRender,仅验证组件自身逻辑
|
|
@@ -17,13 +37,16 @@ vi.mock('markstream-vue', () => ({
|
|
|
17
37
|
}))
|
|
18
38
|
|
|
19
39
|
function mountAiChat(options: {
|
|
20
|
-
transport:
|
|
21
|
-
tools?:
|
|
40
|
+
transport: AiChatProps['transport']
|
|
41
|
+
tools?: AiChatProps['tools']
|
|
22
42
|
welcome?: string | string[]
|
|
23
43
|
models?: ChatModelOption[]
|
|
24
44
|
model?: string
|
|
25
45
|
reasoningLevel?: string
|
|
26
46
|
tokenUsageDetail?: boolean
|
|
47
|
+
messages?: ChatMessage[]
|
|
48
|
+
toolIcons?: Record<string, Component>
|
|
49
|
+
readonly?: boolean
|
|
27
50
|
slots?: Record<string, (scope: any) => any>
|
|
28
51
|
}) {
|
|
29
52
|
const host = document.createElement('div')
|
|
@@ -43,7 +66,10 @@ function mountAiChat(options: {
|
|
|
43
66
|
models: options.models,
|
|
44
67
|
model: options.model,
|
|
45
68
|
reasoningLevel: options.reasoningLevel,
|
|
46
|
-
tokenUsageDetail: options.tokenUsageDetail
|
|
69
|
+
tokenUsageDetail: options.tokenUsageDetail,
|
|
70
|
+
messages: options.messages,
|
|
71
|
+
toolIcons: options.toolIcons,
|
|
72
|
+
readonly: options.readonly
|
|
47
73
|
},
|
|
48
74
|
options.slots
|
|
49
75
|
)
|
|
@@ -1251,4 +1277,376 @@ describe('UAiChat', () => {
|
|
|
1251
1277
|
expect(noCache.host.querySelector('.u-ai-chat__input-usage')?.textContent).not.toContain('缓存')
|
|
1252
1278
|
noCache.unmount()
|
|
1253
1279
|
})
|
|
1280
|
+
|
|
1281
|
+
const UNKNOWN_TOOL = 'totally-unknown-tool-xyz'
|
|
1282
|
+
|
|
1283
|
+
function seedUnknownCall(init: Partial<ChatToolCall> = {}) {
|
|
1284
|
+
const call = reactive<ChatToolCall>({
|
|
1285
|
+
id: 'c1',
|
|
1286
|
+
name: UNKNOWN_TOOL,
|
|
1287
|
+
arguments: '{"q":"hi"}',
|
|
1288
|
+
status: 'pending',
|
|
1289
|
+
...init
|
|
1290
|
+
})
|
|
1291
|
+
const messages: ChatMessage[] = [
|
|
1292
|
+
{ id: 'u1', role: 'user', content: 'go' },
|
|
1293
|
+
{ id: 'a1', role: 'assistant', content: '', status: 'done', toolCalls: [call] }
|
|
1294
|
+
]
|
|
1295
|
+
return { call, messages }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function expandToolCard(host: HTMLElement) {
|
|
1299
|
+
const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')
|
|
1300
|
+
header?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
1301
|
+
await nextTick()
|
|
1302
|
+
await nextTick()
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function assertGenericCard(host: HTMLElement, status: 'pending' | 'success' | 'error') {
|
|
1306
|
+
const card = host.querySelector(`.u-ai-chat__tool-call.is-${status}`)
|
|
1307
|
+
expect(card).toBeTruthy()
|
|
1308
|
+
expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe(UNKNOWN_TOOL)
|
|
1309
|
+
expect(host.querySelector('.u-ai-chat__tool-call-status')).toBeTruthy()
|
|
1310
|
+
expect(host.textContent).not.toContain('未找到')
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
it('未注册工具名 pending → success 走出通用卡片,无空白、无未找到工具文案', async () => {
|
|
1314
|
+
const { call, messages } = seedUnknownCall()
|
|
1315
|
+
const { host, unmount } = mountAiChat({ transport: () => {}, messages })
|
|
1316
|
+
await nextTick()
|
|
1317
|
+
|
|
1318
|
+
assertGenericCard(host, 'pending')
|
|
1319
|
+
expect(host.querySelector('.u-ai-chat__tool-call-name')?.classList.contains('u-shine')).toBe(
|
|
1320
|
+
true
|
|
1321
|
+
)
|
|
1322
|
+
expect(host.textContent).toContain('"q": "hi"')
|
|
1323
|
+
|
|
1324
|
+
call.status = 'success'
|
|
1325
|
+
call.result = '{"ok":true}'
|
|
1326
|
+
await nextTick()
|
|
1327
|
+
assertGenericCard(host, 'success')
|
|
1328
|
+
|
|
1329
|
+
await expandToolCard(host)
|
|
1330
|
+
expect(host.textContent).toContain('"ok": true')
|
|
1331
|
+
unmount()
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
it('未注册工具名 pending → error 走出通用卡片,无空白、无未找到工具文案', async () => {
|
|
1335
|
+
const { call, messages } = seedUnknownCall()
|
|
1336
|
+
const { host, unmount } = mountAiChat({ transport: () => {}, messages })
|
|
1337
|
+
await nextTick()
|
|
1338
|
+
assertGenericCard(host, 'pending')
|
|
1339
|
+
|
|
1340
|
+
call.status = 'error'
|
|
1341
|
+
call.error = 'boom'
|
|
1342
|
+
await nextTick()
|
|
1343
|
+
assertGenericCard(host, 'error')
|
|
1344
|
+
|
|
1345
|
+
await expandToolCard(host)
|
|
1346
|
+
expect(host.textContent).toContain('boom')
|
|
1347
|
+
expect(host.textContent).toContain('错误')
|
|
1348
|
+
unmount()
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
it('通用卡片展示 view,长 JSON 可截断并展开看全文', async () => {
|
|
1352
|
+
const long = 'x'.repeat(500)
|
|
1353
|
+
const { call, messages } = seedUnknownCall({
|
|
1354
|
+
status: 'success',
|
|
1355
|
+
result: JSON.stringify({ data: long }),
|
|
1356
|
+
view: { kind: 'diff', body: 'patch-xyz' }
|
|
1357
|
+
})
|
|
1358
|
+
const { host, unmount } = mountAiChat({ transport: () => {}, messages })
|
|
1359
|
+
await nextTick()
|
|
1360
|
+
await expandToolCard(host)
|
|
1361
|
+
|
|
1362
|
+
expect(host.textContent).toContain('视图')
|
|
1363
|
+
expect(host.textContent).toContain('patch-xyz')
|
|
1364
|
+
expect(host.textContent).not.toContain(long)
|
|
1365
|
+
const toggle = [
|
|
1366
|
+
...host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-json-toggle')
|
|
1367
|
+
].find((el) => el.textContent?.includes('展开'))
|
|
1368
|
+
expect(toggle).toBeTruthy()
|
|
1369
|
+
toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
1370
|
+
await nextTick()
|
|
1371
|
+
expect(host.textContent).toContain(long)
|
|
1372
|
+
unmount()
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
it('toolIcons 可覆盖未知名工具图标', async () => {
|
|
1376
|
+
const OverrideIcon = defineComponent({
|
|
1377
|
+
setup: () => () => h('svg', { class: 'override-tool-icon' })
|
|
1378
|
+
})
|
|
1379
|
+
const { messages } = seedUnknownCall()
|
|
1380
|
+
const { host, unmount } = mountAiChat({
|
|
1381
|
+
transport: () => {},
|
|
1382
|
+
messages,
|
|
1383
|
+
toolIcons: { [UNKNOWN_TOOL]: OverrideIcon }
|
|
1384
|
+
})
|
|
1385
|
+
await nextTick()
|
|
1386
|
+
expect(host.querySelector('.override-tool-icon')).toBeTruthy()
|
|
1387
|
+
unmount()
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
it('session 下不注入内置 askQuestion,无 meta 时走通用视图', async () => {
|
|
1391
|
+
const history: ChatSessionEvent[] = [
|
|
1392
|
+
{
|
|
1393
|
+
type: 'tool/call',
|
|
1394
|
+
callId: 'c1',
|
|
1395
|
+
name: 'askQuestion',
|
|
1396
|
+
arguments: '{"questions":[{"question":"Q?"}]}',
|
|
1397
|
+
seq: 1
|
|
1398
|
+
}
|
|
1399
|
+
]
|
|
1400
|
+
const { host, unmount } = mountAiChat({
|
|
1401
|
+
transport: createServerTransport({
|
|
1402
|
+
subscribe: () => () => {},
|
|
1403
|
+
send: async () => {},
|
|
1404
|
+
cancel: async () => {},
|
|
1405
|
+
respond: async () => {},
|
|
1406
|
+
fetchHistory: async () => ({ events: history, hasMore: false }),
|
|
1407
|
+
selectModel: async () => {}
|
|
1408
|
+
})
|
|
1409
|
+
})
|
|
1410
|
+
await vi.waitFor(() => {
|
|
1411
|
+
expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('askQuestion')
|
|
1412
|
+
})
|
|
1413
|
+
expect(host.querySelector('.u-ai-chat__ask-question')).toBeFalsy()
|
|
1414
|
+
unmount()
|
|
1415
|
+
})
|
|
1416
|
+
|
|
1417
|
+
it('session 下 meta.render 优先于通用视图', async () => {
|
|
1418
|
+
const meta: ChatToolMeta = {
|
|
1419
|
+
name: 'server-tool',
|
|
1420
|
+
label: '服务端工具',
|
|
1421
|
+
render: defineComponent({
|
|
1422
|
+
props: { toolCall: { type: Object as PropType<ChatToolCall>, required: true } },
|
|
1423
|
+
setup: () => () => h('div', { class: 'meta-render' }, 'meta-body')
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1426
|
+
const history: ChatSessionEvent[] = [
|
|
1427
|
+
{ type: 'tool/call', callId: 'c1', name: 'server-tool', arguments: '{}', seq: 1 }
|
|
1428
|
+
]
|
|
1429
|
+
const { host, unmount } = mountAiChat({
|
|
1430
|
+
transport: createServerTransport({
|
|
1431
|
+
subscribe: () => () => {},
|
|
1432
|
+
send: async () => {},
|
|
1433
|
+
cancel: async () => {},
|
|
1434
|
+
respond: async () => {},
|
|
1435
|
+
fetchHistory: async () => ({ events: history, hasMore: false }),
|
|
1436
|
+
selectModel: async () => {}
|
|
1437
|
+
}),
|
|
1438
|
+
tools: [meta]
|
|
1439
|
+
})
|
|
1440
|
+
await vi.waitFor(() => {
|
|
1441
|
+
expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('服务端工具')
|
|
1442
|
+
})
|
|
1443
|
+
expect(host.querySelector('.meta-render')?.textContent).toBe('meta-body')
|
|
1444
|
+
expect(host.textContent).not.toContain('参数')
|
|
1445
|
+
unmount()
|
|
1446
|
+
})
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
function createFakeAdapter(options?: {
|
|
1450
|
+
history?: ChatSessionEvent[]
|
|
1451
|
+
}): ChatSessionAdapter & { emit: (event: ChatSessionEvent) => void } {
|
|
1452
|
+
let listener: { onEvent(event: ChatSessionEvent): void } | undefined
|
|
1453
|
+
const adapter: ChatSessionAdapter = {
|
|
1454
|
+
subscribe(handlers) {
|
|
1455
|
+
listener = handlers
|
|
1456
|
+
return () => {
|
|
1457
|
+
listener = undefined
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
send: vi.fn(async () => {}),
|
|
1461
|
+
cancel: vi.fn(async () => {}),
|
|
1462
|
+
respond: vi.fn(async () => {}),
|
|
1463
|
+
fetchHistory: vi.fn(async () => ({ events: options?.history ?? [], hasMore: false })),
|
|
1464
|
+
selectModel: vi.fn(async () => {})
|
|
1465
|
+
}
|
|
1466
|
+
return Object.assign(adapter, {
|
|
1467
|
+
emit(event: ChatSessionEvent) {
|
|
1468
|
+
listener?.onEvent(event)
|
|
1469
|
+
}
|
|
1470
|
+
})
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function mountSession(options?: {
|
|
1474
|
+
history?: ChatSessionEvent[]
|
|
1475
|
+
readonly?: boolean
|
|
1476
|
+
welcome?: string | string[]
|
|
1477
|
+
}) {
|
|
1478
|
+
const adapter = createFakeAdapter({ history: options?.history })
|
|
1479
|
+
const mounted = mountAiChat({
|
|
1480
|
+
transport: createServerTransport(adapter),
|
|
1481
|
+
readonly: options?.readonly,
|
|
1482
|
+
welcome: options?.welcome
|
|
1483
|
+
})
|
|
1484
|
+
await vi.waitFor(() => {
|
|
1485
|
+
expect(adapter.fetchHistory).toHaveBeenCalled()
|
|
1486
|
+
})
|
|
1487
|
+
return { ...mounted, adapter }
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const click = (el: HTMLElement) => el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
1491
|
+
|
|
1492
|
+
describe('UAiChat P4 作业条 / 审批 / 只读 / 提问', () => {
|
|
1493
|
+
it('无 jobs 不渲染作业条;有 jobs 显示图标、文案、状态点,进行中扫光', async () => {
|
|
1494
|
+
const { host, adapter, unmount } = await mountSession()
|
|
1495
|
+
expect(host.querySelector('.u-ai-chat__job-bar')).toBeFalsy()
|
|
1496
|
+
|
|
1497
|
+
adapter.emit({
|
|
1498
|
+
type: 'jobs/snapshot',
|
|
1499
|
+
jobs: [{ id: 'j1', kind: 'bash', label: '跑测试', status: 'running' }]
|
|
1500
|
+
})
|
|
1501
|
+
await nextTick()
|
|
1502
|
+
|
|
1503
|
+
expect(host.querySelector('.u-ai-chat__job-bar')).toBeTruthy()
|
|
1504
|
+
expect(host.querySelector('.u-ai-chat__job-bar-label')?.textContent).toContain('跑测试')
|
|
1505
|
+
expect(host.querySelector('.u-ai-chat__job-bar-label')?.classList.contains('u-shine')).toBe(
|
|
1506
|
+
true
|
|
1507
|
+
)
|
|
1508
|
+
expect(host.querySelector('.u-ai-chat__job-bar-dot.is-running')).toBeTruthy()
|
|
1509
|
+
|
|
1510
|
+
host.querySelector<HTMLElement>('.u-ai-chat__job-bar-head')!.click()
|
|
1511
|
+
await nextTick()
|
|
1512
|
+
expect(host.querySelector('.u-ai-chat__job-bar-item')).toBeFalsy()
|
|
1513
|
+
unmount()
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
it('审批横幅仅无 callId 出现;有 callId 走工具卡确认', async () => {
|
|
1517
|
+
const { host, adapter, unmount } = await mountSession()
|
|
1518
|
+
|
|
1519
|
+
adapter.emit({ type: 'tool/call', callId: 'c1', name: 'bash', arguments: '{}', seq: 1 })
|
|
1520
|
+
adapter.emit({
|
|
1521
|
+
type: 'approval/requested',
|
|
1522
|
+
approvalId: 'a1',
|
|
1523
|
+
toolName: 'bash',
|
|
1524
|
+
callId: 'c1',
|
|
1525
|
+
rpcId: 'rpc-card'
|
|
1526
|
+
})
|
|
1527
|
+
await nextTick()
|
|
1528
|
+
|
|
1529
|
+
expect(host.querySelector('.u-ai-chat__approval-banner-card')).toBeFalsy()
|
|
1530
|
+
expect(host.querySelector('.u-ai-chat__tool-call-confirm')).toBeTruthy()
|
|
1531
|
+
|
|
1532
|
+
adapter.emit({
|
|
1533
|
+
type: 'approval/requested',
|
|
1534
|
+
approvalId: 'a2',
|
|
1535
|
+
toolName: 'deploy',
|
|
1536
|
+
reason: '发布到生产',
|
|
1537
|
+
rpcId: 'rpc-banner'
|
|
1538
|
+
})
|
|
1539
|
+
await nextTick()
|
|
1540
|
+
|
|
1541
|
+
expect(host.querySelector('.u-ai-chat__approval-banner-card')).toBeTruthy()
|
|
1542
|
+
expect(host.textContent).toContain('发布到生产')
|
|
1543
|
+
|
|
1544
|
+
const allow = [
|
|
1545
|
+
...host.querySelectorAll<HTMLElement>('.u-ai-chat__approval-banner-card button')
|
|
1546
|
+
].find((btn) => btn.textContent?.includes('允许'))!
|
|
1547
|
+
click(allow)
|
|
1548
|
+
await vi.waitFor(() => {
|
|
1549
|
+
expect(adapter.respond).toHaveBeenCalledWith('rpc-banner', true, undefined)
|
|
1550
|
+
})
|
|
1551
|
+
unmount()
|
|
1552
|
+
})
|
|
1553
|
+
|
|
1554
|
+
it('readonly 不展示输入区,欢迎语点击不发送,队列无插队编辑移除', async () => {
|
|
1555
|
+
const { host, adapter, unmount } = await mountSession({ readonly: true, welcome: '点我发送' })
|
|
1556
|
+
expect(host.querySelector('.u-ai-chat__input-area')).toBeFalsy()
|
|
1557
|
+
|
|
1558
|
+
host.querySelector<HTMLElement>('.u-ai-chat__welcome-item')!.click()
|
|
1559
|
+
await nextTick()
|
|
1560
|
+
expect(host.querySelector('.u-ai-chat__message--user')).toBeFalsy()
|
|
1561
|
+
|
|
1562
|
+
adapter.emit({ type: 'queue/snapshot', items: [{ id: 'q1', content: '排队中' }] })
|
|
1563
|
+
await nextTick()
|
|
1564
|
+
expect(host.querySelector('.u-ai-chat__queue-text')?.textContent).toContain('排队中')
|
|
1565
|
+
expect(host.querySelector('.u-ai-chat__queue-actions')).toBeFalsy()
|
|
1566
|
+
unmount()
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
it('非只读客户端输入区与欢迎点击发送与现状一致', async () => {
|
|
1570
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
1571
|
+
handlers.onTextDelta('收到')
|
|
1572
|
+
}
|
|
1573
|
+
const { host, unmount } = mountAiChat({ transport, welcome: '快捷提问' })
|
|
1574
|
+
expect(host.querySelector('.u-ai-chat__input-area')).toBeTruthy()
|
|
1575
|
+
host.querySelector<HTMLElement>('.u-ai-chat__welcome-item')!.click()
|
|
1576
|
+
await nextTick()
|
|
1577
|
+
expect(
|
|
1578
|
+
host.querySelector('.u-ai-chat__message--user .u-ai-chat__message-bubble')?.textContent
|
|
1579
|
+
).toContain('快捷提问')
|
|
1580
|
+
unmount()
|
|
1581
|
+
})
|
|
1582
|
+
|
|
1583
|
+
it('ask-question 客户端提交走 resolveAskQuestion', async () => {
|
|
1584
|
+
const rounds: ChatMessage[][] = []
|
|
1585
|
+
let round = 0
|
|
1586
|
+
const transport: ChatTransport = (req, handlers) => {
|
|
1587
|
+
round++
|
|
1588
|
+
rounds.push(req.messages)
|
|
1589
|
+
if (round === 1) {
|
|
1590
|
+
handlers.onToolCall?.({
|
|
1591
|
+
id: 'q1',
|
|
1592
|
+
name: 'askQuestion',
|
|
1593
|
+
arguments: JSON.stringify({ questions: [{ question: 'Q?', options: ['A'] }] })
|
|
1594
|
+
})
|
|
1595
|
+
} else {
|
|
1596
|
+
handlers.onTextDelta('明白了')
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const { host, chat, unmount } = mountAiChat({ transport })
|
|
1600
|
+
chat.value?.send('提问')
|
|
1601
|
+
await vi.waitFor(() => {
|
|
1602
|
+
expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain('Q?')
|
|
1603
|
+
})
|
|
1604
|
+
click(
|
|
1605
|
+
[...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-opt')].find((el) =>
|
|
1606
|
+
el.textContent?.includes('A')
|
|
1607
|
+
)!
|
|
1608
|
+
)
|
|
1609
|
+
await nextTick()
|
|
1610
|
+
click(
|
|
1611
|
+
[...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-actions button')].find(
|
|
1612
|
+
(btn) => btn.textContent?.includes('提交')
|
|
1613
|
+
)!
|
|
1614
|
+
)
|
|
1615
|
+
await vi.waitFor(() => {
|
|
1616
|
+
expect(rounds.length).toBe(2)
|
|
1617
|
+
})
|
|
1618
|
+
const toolMessage = rounds[1]![rounds[1]!.length - 1]!
|
|
1619
|
+
expect(toolMessage.role).toBe('tool')
|
|
1620
|
+
expect(JSON.parse(toolMessage.content)).toEqual({ answers: [{ question: 'Q?', answer: 'A' }] })
|
|
1621
|
+
unmount()
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
it('ask-question session 提交走 session.respond', async () => {
|
|
1625
|
+
const { host, adapter, unmount } = await mountSession()
|
|
1626
|
+
adapter.emit({
|
|
1627
|
+
type: 'question/requested',
|
|
1628
|
+
rpcId: 'rpc-q',
|
|
1629
|
+
questions: [{ question: '选一个', options: ['A', 'B'] }]
|
|
1630
|
+
})
|
|
1631
|
+
await nextTick()
|
|
1632
|
+
expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain('选一个')
|
|
1633
|
+
|
|
1634
|
+
click(
|
|
1635
|
+
[...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-opt')].find((el) =>
|
|
1636
|
+
el.textContent?.includes('A')
|
|
1637
|
+
)!
|
|
1638
|
+
)
|
|
1639
|
+
await nextTick()
|
|
1640
|
+
click(
|
|
1641
|
+
[...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-actions button')].find(
|
|
1642
|
+
(btn) => btn.textContent?.includes('提交')
|
|
1643
|
+
)!
|
|
1644
|
+
)
|
|
1645
|
+
await vi.waitFor(() => {
|
|
1646
|
+
expect(adapter.respond).toHaveBeenCalledWith('rpc-q', true, [
|
|
1647
|
+
{ question: '选一个', answer: 'A' }
|
|
1648
|
+
])
|
|
1649
|
+
})
|
|
1650
|
+
unmount()
|
|
1651
|
+
})
|
|
1254
1652
|
})
|