@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.
Files changed (74) hide show
  1. package/README.md +42 -0
  2. package/dist/chat/fold.d.ts +41 -0
  3. package/dist/chat/fold.js +208 -0
  4. package/dist/chat/fold.js.map +1 -0
  5. package/dist/chat/index.d.ts +4 -2
  6. package/dist/chat/session.d.ts +123 -0
  7. package/dist/chat/session.js +79 -0
  8. package/dist/chat/session.js.map +1 -0
  9. package/dist/chat/types.d.ts +38 -1
  10. package/dist/chat/use-chat-helpers.js +39 -0
  11. package/dist/chat/use-chat-helpers.js.map +1 -0
  12. package/dist/chat/use-chat-session.js +68 -0
  13. package/dist/chat/use-chat-session.js.map +1 -0
  14. package/dist/chat/use-chat.d.ts +50 -2
  15. package/dist/chat/use-chat.js +73 -39
  16. package/dist/chat/use-chat.js.map +1 -1
  17. package/dist/components/ai-chat/ai-chat.js +49 -14
  18. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  19. package/dist/components/ai-chat/approval-banner.js +54 -0
  20. package/dist/components/ai-chat/approval-banner.js.map +1 -0
  21. package/dist/components/ai-chat/ask-question.js +32 -51
  22. package/dist/components/ai-chat/ask-question.js.map +1 -1
  23. package/dist/components/ai-chat/chat-input.js +9 -3
  24. package/dist/components/ai-chat/chat-input.js.map +1 -1
  25. package/dist/components/ai-chat/di.js.map +1 -1
  26. package/dist/components/ai-chat/job-bar.js +48 -0
  27. package/dist/components/ai-chat/job-bar.js.map +1 -0
  28. package/dist/components/ai-chat/message-list.js +5 -2
  29. package/dist/components/ai-chat/message-list.js.map +1 -1
  30. package/dist/components/ai-chat/queue-list.js +9 -3
  31. package/dist/components/ai-chat/queue-list.js.map +1 -1
  32. package/dist/components/ai-chat/side-panel.js +12 -30
  33. package/dist/components/ai-chat/side-panel.js.map +1 -1
  34. package/dist/components/ai-chat/style2.css +150 -8
  35. package/dist/components/ai-chat/tool-call.js +51 -40
  36. package/dist/components/ai-chat/tool-call.js.map +1 -1
  37. package/dist/components/ai-chat/tool-icons.js +31 -0
  38. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  39. package/dist/components/ai-chat/tool-json.js +40 -0
  40. package/dist/components/ai-chat/tool-json.js.map +1 -0
  41. package/dist/components/ai-orb/style2.css +1 -1
  42. package/dist/index.d.ts +6 -4
  43. package/dist/index.js +3 -1
  44. package/dist/tools/ask-question/ask-question.js +48 -2
  45. package/dist/tools/ask-question/ask-question.js.map +1 -1
  46. package/dist/types/ai-chat.d.ts +17 -6
  47. package/package.json +7 -7
  48. package/src/chat/__test__/fold.test.ts +136 -0
  49. package/src/chat/__test__/session.test.ts +191 -0
  50. package/src/chat/__test__/use-chat.test.ts +268 -1
  51. package/src/chat/fold.ts +260 -0
  52. package/src/chat/index.ts +2 -0
  53. package/src/chat/session.ts +168 -0
  54. package/src/chat/types.ts +40 -0
  55. package/src/chat/use-chat-helpers.ts +39 -0
  56. package/src/chat/use-chat-session.ts +87 -0
  57. package/src/chat/use-chat.ts +90 -44
  58. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  59. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  60. package/src/components/ai-chat/ai-chat.vue +39 -4
  61. package/src/components/ai-chat/approval-banner.vue +37 -0
  62. package/src/components/ai-chat/ask-question.vue +38 -54
  63. package/src/components/ai-chat/chat-input.vue +10 -2
  64. package/src/components/ai-chat/di.ts +6 -4
  65. package/src/components/ai-chat/job-bar.vue +41 -0
  66. package/src/components/ai-chat/message-list.vue +4 -1
  67. package/src/components/ai-chat/queue-list.vue +3 -1
  68. package/src/components/ai-chat/side-panel.vue +15 -13
  69. package/src/components/ai-chat/style.scss +162 -6
  70. package/src/components/ai-chat/tool-call.vue +46 -34
  71. package/src/components/ai-chat/tool-icons.ts +33 -0
  72. package/src/components/ai-chat/tool-json.vue +52 -0
  73. package/src/tools/ask-question/ask-question.ts +53 -4
  74. package/src/types/ai-chat.ts +17 -5
@@ -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 { createBuiltinTools } from '../tools'
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
- * 始终注入内置工具(如 askQuestion),无需经 props 传入。
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
- if (!props.transport) return
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 props.transport(
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 { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
3
-
4
- import type { ChatMessage, ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
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: ChatTransport
21
- tools?: ChatTool[]
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
  })