@veltra/ai 2.1.6 → 2.1.8

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 (71) 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/di.js.map +1 -1
  24. package/dist/components/ai-chat/job-bar.js +48 -0
  25. package/dist/components/ai-chat/job-bar.js.map +1 -0
  26. package/dist/components/ai-chat/message-list.js +5 -2
  27. package/dist/components/ai-chat/message-list.js.map +1 -1
  28. package/dist/components/ai-chat/queue-list.js +9 -3
  29. package/dist/components/ai-chat/queue-list.js.map +1 -1
  30. package/dist/components/ai-chat/side-panel.js +12 -30
  31. package/dist/components/ai-chat/side-panel.js.map +1 -1
  32. package/dist/components/ai-chat/style2.css +146 -8
  33. package/dist/components/ai-chat/tool-call.js +51 -40
  34. package/dist/components/ai-chat/tool-call.js.map +1 -1
  35. package/dist/components/ai-chat/tool-icons.js +31 -0
  36. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  37. package/dist/components/ai-chat/tool-json.js +40 -0
  38. package/dist/components/ai-chat/tool-json.js.map +1 -0
  39. package/dist/components/ai-orb/style2.css +1 -1
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +3 -1
  42. package/dist/tools/ask-question/ask-question.js +48 -2
  43. package/dist/tools/ask-question/ask-question.js.map +1 -1
  44. package/dist/types/ai-chat.d.ts +17 -6
  45. package/package.json +7 -7
  46. package/src/chat/__test__/fold.test.ts +136 -0
  47. package/src/chat/__test__/session.test.ts +191 -0
  48. package/src/chat/__test__/use-chat.test.ts +268 -1
  49. package/src/chat/fold.ts +260 -0
  50. package/src/chat/index.ts +2 -0
  51. package/src/chat/session.ts +168 -0
  52. package/src/chat/types.ts +40 -0
  53. package/src/chat/use-chat-helpers.ts +39 -0
  54. package/src/chat/use-chat-session.ts +87 -0
  55. package/src/chat/use-chat.ts +90 -44
  56. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  57. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  58. package/src/components/ai-chat/ai-chat.vue +39 -4
  59. package/src/components/ai-chat/approval-banner.vue +37 -0
  60. package/src/components/ai-chat/ask-question.vue +38 -54
  61. package/src/components/ai-chat/di.ts +6 -4
  62. package/src/components/ai-chat/job-bar.vue +41 -0
  63. package/src/components/ai-chat/message-list.vue +4 -1
  64. package/src/components/ai-chat/queue-list.vue +3 -1
  65. package/src/components/ai-chat/side-panel.vue +15 -13
  66. package/src/components/ai-chat/style.scss +158 -6
  67. package/src/components/ai-chat/tool-call.vue +46 -34
  68. package/src/components/ai-chat/tool-icons.ts +33 -0
  69. package/src/components/ai-chat/tool-json.vue +52 -0
  70. package/src/tools/ask-question/ask-question.ts +53 -4
  71. package/src/types/ai-chat.ts +17 -5
@@ -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
  })
@@ -0,0 +1,33 @@
1
+ import { EditPen, Folder, Internet, QuestionFilled, Terminal, Tools } from '@veltra/icons/normal'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { defineComponent, h } from 'vue'
4
+
5
+ import { FALLBACK_TOOL_ICON, resolveToolIcon } from '../tool-icons'
6
+
7
+ describe('resolveToolIcon', () => {
8
+ it('按名称子串匹配内置规则', () => {
9
+ expect(resolveToolIcon('run_bash')).toBe(Terminal)
10
+ expect(resolveToolIcon('Terminal')).toBe(Terminal)
11
+ expect(resolveToolIcon('fs_read')).toBe(Folder)
12
+ expect(resolveToolIcon('read_file')).toBe(Folder)
13
+ expect(resolveToolIcon('web_search')).toBe(Internet)
14
+ expect(resolveToolIcon('globe-fetch')).toBe(Internet)
15
+ expect(resolveToolIcon('str_replace_editor')).toBe(EditPen)
16
+ expect(resolveToolIcon('pencil')).toBe(EditPen)
17
+ expect(resolveToolIcon('askQuestion')).toBe(QuestionFilled)
18
+ expect(resolveToolIcon('clarifying_question')).toBe(QuestionFilled)
19
+ })
20
+
21
+ it('未知名与空名走兜底,不得 throw', () => {
22
+ expect(resolveToolIcon('totally-unknown-tool-xyz')).toBe(Tools)
23
+ expect(resolveToolIcon('totally-unknown-tool-xyz')).toBe(FALLBACK_TOOL_ICON)
24
+ expect(resolveToolIcon('')).toBe(FALLBACK_TOOL_ICON)
25
+ expect(() => resolveToolIcon('totally-unknown-tool-xyz')).not.toThrow()
26
+ })
27
+
28
+ it('宿主覆盖表精确名优先于内置规则', () => {
29
+ const Override = defineComponent({ setup: () => () => h('span') })
30
+ expect(resolveToolIcon('run_bash', { run_bash: Override })).toBe(Override)
31
+ expect(resolveToolIcon('run_bash', { other: Override })).toBe(Terminal)
32
+ })
33
+ })
@@ -14,6 +14,7 @@
14
14
  :messages="messages"
15
15
  :welcome="welcome"
16
16
  :running="running"
17
+ :readonly="readonly"
17
18
  :renderer-props="rendererProps"
18
19
  @respond="respondToolCall"
19
20
  @regenerate="regenerate"
@@ -24,14 +25,24 @@
24
25
  </template>
25
26
  </MessageList>
26
27
 
28
+ <div v-if="pendingQuestion" :class="cls.e('session-question')">
29
+ <AskQuestion :questions="pendingQuestion.questions" @submit="handleQuestionSubmit" />
30
+ </div>
31
+
32
+ <ApprovalBanner :approvals="pendingApprovals" @respond="respondSession" />
33
+
27
34
  <QueueList
28
35
  :queue="queue"
36
+ :readonly="readonly"
29
37
  @start-now="startQueued"
30
38
  @edit="handleQueueEdit"
31
39
  @remove="removeQueued"
32
40
  />
33
41
 
42
+ <JobBar :jobs="jobs" />
43
+
34
44
  <ChatInput
45
+ v-if="!readonly"
35
46
  ref="chatInputRef"
36
47
  v-model:model="model"
37
48
  v-model:reasoning-level="reasoningLevel"
@@ -65,12 +76,17 @@ import { ULayout } from '@veltra/desktop'
65
76
  import { bem } from '@veltra/utils'
66
77
  import { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'
67
78
 
68
- import type { ChatAttachment, ChatToolCall } from '../../chat/types'
79
+ import { isServerTransport } from '../../chat/session'
80
+ import type { ChatAttachment, ChatTool, ChatToolCall, ChatToolMeta } from '../../chat/types'
69
81
  import { useChat } from '../../chat/use-chat'
70
82
  import { createBuiltinTools } from '../../tools'
83
+ import type { AskQuestionAnswer, AskQuestionResult } from '../../tools/ask-question/ask-question'
71
84
  import type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'
85
+ import ApprovalBanner from './approval-banner.vue'
86
+ import AskQuestion from './ask-question.vue'
72
87
  import ChatInput from './chat-input.vue'
73
88
  import { AiChatDIKey } from './di'
89
+ import JobBar from './job-bar.vue'
74
90
  import MessageList from './message-list.vue'
75
91
  import QueueList from './queue-list.vue'
76
92
  import SidePanel from './side-panel.vue'
@@ -92,27 +108,39 @@ const cls = bem('ai-chat')
92
108
 
93
109
  const slots = useSlots()
94
110
 
95
- /** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/renderTo/autoCollapse */
96
- const toolMap = computed(() => {
111
+ /**
112
+ * 按 name 查表:session 下 tools 仅为渲染元信息(不注入内置工具,忽略 execute 等)。
113
+ * 函数 transport 仍注入 createBuiltinTools(同名内置优先)。
114
+ */
115
+ const toolMap = computed((): Record<string, ChatTool | ChatToolMeta | undefined> => {
116
+ if (isServerTransport(props.transport)) {
117
+ return Object.fromEntries((props.tools ?? []).map((t) => [t.name, t]))
118
+ }
97
119
  const builtins = createBuiltinTools()
98
120
  const names = new Set(builtins.map((t) => t.name))
99
121
  const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]
100
122
  return Object.fromEntries(tools.map((t) => [t.name, t]))
101
123
  })
102
124
 
125
+ const toolIcons = computed(() => props.toolIcons)
126
+
103
127
  const {
104
128
  messages,
105
129
  model,
106
130
  reasoningLevel,
107
131
  running,
108
132
  queue,
133
+ jobs,
109
134
  tokenUsage,
110
135
  lastTurnUsage,
136
+ pendingApprovals,
137
+ pendingQuestion,
111
138
  send,
112
139
  abort,
113
140
  regenerate,
114
141
  clear,
115
142
  respondToolCall,
143
+ respondSession,
116
144
  enqueue,
117
145
  startQueued,
118
146
  removeQueued
@@ -175,7 +203,7 @@ const closePanel = () => {
175
203
  activePanelCallId.value = null
176
204
  }
177
205
 
178
- provide(AiChatDIKey, { cls, slots, tools: toolMap, openPanel })
206
+ provide(AiChatDIKey, { cls, slots, tools: toolMap, toolIcons, openPanel })
179
207
 
180
208
  /**
181
209
  * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),
@@ -266,6 +294,7 @@ const restoreEditing = () => {
266
294
  }
267
295
 
268
296
  const handleSend = (content: string, attachments: ChatAttachment[]) => {
297
+ if (props.readonly) return
269
298
  // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)
270
299
  if (editingSuccessor.value !== undefined) {
271
300
  enqueue(content, attachments, editingSuccessor.value ?? undefined)
@@ -275,6 +304,12 @@ const handleSend = (content: string, attachments: ChatAttachment[]) => {
275
304
  send(content, attachments)
276
305
  }
277
306
 
307
+ const handleQuestionSubmit = (value: AskQuestionAnswer[] | AskQuestionResult) => {
308
+ const pending = pendingQuestion.value
309
+ if (!pending) return
310
+ respondSession(pending.rpcId, true, Array.isArray(value) ? value : value.answers)
311
+ }
312
+
278
313
  defineExpose<_AiChatExposed>({
279
314
  send,
280
315
  abort,
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div v-if="items.length" :class="cls.e('approval-banner')">
3
+ <div v-for="item in items" :key="item.approvalId" :class="cls.e('approval-banner-card')">
4
+ <div :class="cls.e('approval-banner-title')">需要确认</div>
5
+ <div v-if="item.reason || item.toolName" :class="cls.e('approval-banner-reason')">
6
+ {{ item.reason ?? item.toolName }}
7
+ </div>
8
+ <div :class="cls.e('approval-banner-actions')">
9
+ <UButton size="small" type="primary" @click="emit('respond', item.rpcId, true)">
10
+ 允许
11
+ </UButton>
12
+ <UButton size="small" text @click="emit('respond', item.rpcId, false)">拒绝</UButton>
13
+ </div>
14
+ </div>
15
+ </div>
16
+ </template>
17
+
18
+ <script lang="ts" setup>
19
+ import { UButton } from '@veltra/desktop'
20
+ import { bem } from '@veltra/utils'
21
+ import { computed, inject } from 'vue'
22
+
23
+ import type { ChatPendingApproval } from '../../chat/fold'
24
+ import { AiChatDIKey } from './di'
25
+
26
+ defineOptions({ name: 'UAiChatApprovalBanner' })
27
+
28
+ const props = defineProps<{ approvals: ChatPendingApproval[] }>()
29
+
30
+ const emit = defineEmits<{ (e: 'respond', rpcId: string, ok: boolean): void }>()
31
+
32
+ const di = inject(AiChatDIKey)
33
+ const cls = di?.cls ?? bem('ai-chat')
34
+
35
+ /** 仅无 callId 的审批走横幅;有 callId 的走工具卡确认 */
36
+ const items = computed(() => props.approvals.filter((item) => !item.callId))
37
+ </script>