@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.
- 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/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 +146 -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/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 +158 -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
|
@@ -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
|
})
|
|
@@ -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
|
|
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
|
-
/**
|
|
96
|
-
|
|
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>
|