@veltra/ai 1.4.0

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/dist/chat/index.d.ts +5 -0
  2. package/dist/chat/transports/index.d.ts +2 -0
  3. package/dist/chat/transports/openai.d.ts +23 -0
  4. package/dist/chat/transports/openai.js +176 -0
  5. package/dist/chat/transports/openai.js.map +1 -0
  6. package/dist/chat/types.d.ts +125 -0
  7. package/dist/chat/use-chat.d.ts +25 -0
  8. package/dist/chat/use-chat.js +214 -0
  9. package/dist/chat/use-chat.js.map +1 -0
  10. package/dist/components/ai-chat/ai-chat.js +88 -0
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -0
  12. package/dist/components/ai-chat/ai-chat.vue.d.ts +36 -0
  13. package/dist/components/ai-chat/ask-question.js +187 -0
  14. package/dist/components/ai-chat/ask-question.js.map +1 -0
  15. package/dist/components/ai-chat/chat-input.js +143 -0
  16. package/dist/components/ai-chat/chat-input.js.map +1 -0
  17. package/dist/components/ai-chat/di.js +6 -0
  18. package/dist/components/ai-chat/di.js.map +1 -0
  19. package/dist/components/ai-chat/index.d.ts +2 -0
  20. package/dist/components/ai-chat/message-item.js +120 -0
  21. package/dist/components/ai-chat/message-item.js.map +1 -0
  22. package/dist/components/ai-chat/message-list.js +75 -0
  23. package/dist/components/ai-chat/message-list.js.map +1 -0
  24. package/dist/components/ai-chat/style.d.ts +5 -0
  25. package/dist/components/ai-chat/style.js +6 -0
  26. package/dist/components/ai-chat/style2.css +416 -0
  27. package/dist/components/ai-chat/tool-call.js +151 -0
  28. package/dist/components/ai-chat/tool-call.js.map +1 -0
  29. package/dist/components/index.d.ts +3 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.js +5 -0
  32. package/dist/style.d.ts +1 -0
  33. package/dist/style.js +1 -0
  34. package/dist/tools/ask-question/ask-question.d.ts +45 -0
  35. package/dist/tools/ask-question/ask-question.js +65 -0
  36. package/dist/tools/ask-question/ask-question.js.map +1 -0
  37. package/dist/tools/ask-question/deferred.js +22 -0
  38. package/dist/tools/ask-question/deferred.js.map +1 -0
  39. package/dist/tools/ask-question/index.d.ts +2 -0
  40. package/dist/tools/index.d.ts +3 -0
  41. package/dist/types/ai-chat.d.ts +49 -0
  42. package/dist/types/index.d.ts +2 -0
  43. package/package.json +40 -0
  44. package/src/chat/__test__/use-chat.test.ts +246 -0
  45. package/src/chat/index.ts +3 -0
  46. package/src/chat/transports/index.ts +1 -0
  47. package/src/chat/transports/openai.ts +237 -0
  48. package/src/chat/types.ts +136 -0
  49. package/src/chat/use-chat.ts +245 -0
  50. package/src/components/ai-chat/__test__/ai-chat.test.ts +316 -0
  51. package/src/components/ai-chat/ai-chat.vue +67 -0
  52. package/src/components/ai-chat/ask-question.vue +232 -0
  53. package/src/components/ai-chat/chat-input.vue +127 -0
  54. package/src/components/ai-chat/di.ts +15 -0
  55. package/src/components/ai-chat/index.ts +1 -0
  56. package/src/components/ai-chat/message-item.vue +120 -0
  57. package/src/components/ai-chat/message-list.vue +81 -0
  58. package/src/components/ai-chat/style.scss +439 -0
  59. package/src/components/ai-chat/style.ts +6 -0
  60. package/src/components/ai-chat/tool-call.vue +147 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/env.d.ts +12 -0
  63. package/src/index.ts +4 -0
  64. package/src/style.ts +1 -0
  65. package/src/tools/ask-question/__test__/ask-question.test.ts +198 -0
  66. package/src/tools/ask-question/ask-question.ts +114 -0
  67. package/src/tools/ask-question/deferred.ts +27 -0
  68. package/src/tools/ask-question/index.ts +1 -0
  69. package/src/tools/index.ts +1 -0
  70. package/src/types/ai-chat.ts +56 -0
  71. package/src/types/index.ts +1 -0
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createApp, defineComponent, h, nextTick, ref } from 'vue'
3
+
4
+ import type { ChatMessage, ChatTransport } from '../../../chat/types'
5
+ import UAiChat from '../../../components/ai-chat/ai-chat.vue'
6
+ import type { AiChatExposed } from '../../../types'
7
+ import { createAskQuestionTool } from '../ask-question'
8
+
9
+ // happy-dom 环境下用桩组件替换 MarkdownRender,仅验证组件自身逻辑
10
+ vi.mock('markstream-vue', () => ({
11
+ default: defineComponent({
12
+ props: { content: { type: String, default: '' } },
13
+ setup(props) {
14
+ return () => h('div', { class: 'md-stub' }, props.content)
15
+ }
16
+ })
17
+ }))
18
+
19
+ function mountAiChat(transport: ChatTransport) {
20
+ const host = document.createElement('div')
21
+ document.body.appendChild(host)
22
+
23
+ const chatRef = ref<AiChatExposed>()
24
+
25
+ const app = createApp({
26
+ render() {
27
+ return h(UAiChat, { ref: chatRef, transport, tools: [createAskQuestionTool()] })
28
+ }
29
+ })
30
+
31
+ app.mount(host)
32
+
33
+ return {
34
+ host,
35
+ chat: chatRef,
36
+ unmount() {
37
+ app.unmount()
38
+ host.remove()
39
+ }
40
+ }
41
+ }
42
+
43
+ const QUESTIONS = [
44
+ { question: '目标用户是谁?', options: ['开发者', '设计师'] },
45
+ { question: '偏好什么风格?', options: ['简洁', '炫酷'] },
46
+ { question: '还有什么补充?', placeholder: '例如:需要深色模式' }
47
+ ]
48
+
49
+ /** 第一轮发起提问工具调用、第二轮输出文本的 transport;rounds 记录每轮收到的消息 */
50
+ function createAskTransport(rounds: ChatMessage[][]) {
51
+ let round = 0
52
+ const transport: ChatTransport = (req, handlers) => {
53
+ round++
54
+ rounds.push(req.messages)
55
+ if (round === 1) {
56
+ handlers.onToolCall?.({
57
+ id: 'q1',
58
+ name: 'askQuestion',
59
+ arguments: JSON.stringify({ questions: QUESTIONS })
60
+ })
61
+ } else {
62
+ handlers.onTextDelta('明白了')
63
+ }
64
+ }
65
+ return transport
66
+ }
67
+
68
+ const click = (el: HTMLElement) => el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
69
+
70
+ /** 底部导航按钮(UButton disabled 体现为 is-disabled class) */
71
+ const actionButton = (host: HTMLElement, text: string) => {
72
+ return [...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-actions button')].find(
73
+ (btn) => btn.textContent?.includes(text)
74
+ )
75
+ }
76
+
77
+ /** 当前题的选项按钮 */
78
+ const optionButton = (host: HTMLElement, text: string) => {
79
+ return [...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-opt')].find((el) =>
80
+ el.textContent?.includes(text)
81
+ )
82
+ }
83
+
84
+ describe('createAskQuestionTool', () => {
85
+ it('分页提问:导航、作答、提交后展示问答摘要', async () => {
86
+ const rounds: ChatMessage[][] = []
87
+ const { host, chat, unmount } = mountAiChat(createAskTransport(rounds))
88
+
89
+ chat.value?.send('帮我做个页面')
90
+
91
+ // 第 1 题:进度、选项、「上一个」禁用、无「提交」
92
+ await vi.waitFor(() => {
93
+ expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain(
94
+ '目标用户是谁?'
95
+ )
96
+ })
97
+ expect(host.querySelector('.u-ai-chat__ask-question-count')?.textContent).toBe('1 / 3')
98
+ expect(actionButton(host, '上一个')?.className).toContain('is-disabled')
99
+ expect(actionButton(host, '下一个')).toBeTruthy()
100
+ expect(actionButton(host, '提交')).toBeUndefined()
101
+
102
+ // 点选「开发者」
103
+ click(optionButton(host, '开发者')!)
104
+ await nextTick()
105
+ expect(optionButton(host, '开发者')?.className).toContain('is-selected')
106
+
107
+ // 下一个 → 第 2 题,点选「简洁」
108
+ click(actionButton(host, '下一个')!)
109
+ await nextTick()
110
+ expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain(
111
+ '偏好什么风格?'
112
+ )
113
+ click(optionButton(host, '简洁')!)
114
+ await nextTick()
115
+
116
+ // 下一个 → 第 3 题(纯文本),「下一个」变为「提交」,未作答时禁用
117
+ click(actionButton(host, '下一个')!)
118
+ await nextTick()
119
+ expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain(
120
+ '还有什么补充?'
121
+ )
122
+ expect(actionButton(host, '下一个')).toBeUndefined()
123
+ expect(actionButton(host, '提交')?.className).toContain('is-disabled')
124
+
125
+ // 自定义输入作答后「提交」可用
126
+ const input = host.querySelector<HTMLInputElement>('.u-ai-chat__ask-question input')!
127
+ input.value = '需要深色模式'
128
+ input.dispatchEvent(new Event('input', { bubbles: true }))
129
+ await nextTick()
130
+ expect(actionButton(host, '提交')?.className).not.toContain('is-disabled')
131
+
132
+ // 「上一个」返回第 1 题,选中态保留
133
+ click(actionButton(host, '上一个')!)
134
+ await nextTick()
135
+ click(actionButton(host, '上一个')!)
136
+ await nextTick()
137
+ expect(host.querySelector('.u-ai-chat__ask-question-q')?.textContent).toContain(
138
+ '目标用户是谁?'
139
+ )
140
+ expect(optionButton(host, '开发者')?.className).toContain('is-selected')
141
+
142
+ // 回到最后提交
143
+ click(actionButton(host, '下一个')!)
144
+ await nextTick()
145
+ click(actionButton(host, '下一个')!)
146
+ await nextTick()
147
+ click(actionButton(host, '提交')!)
148
+
149
+ // 第二轮 transport 收到含回答的 tool 消息
150
+ await vi.waitFor(() => {
151
+ expect(rounds.length).toBe(2)
152
+ })
153
+ const toolMessage = rounds[1]![rounds[1]!.length - 1]!
154
+ expect(toolMessage.role).toBe('tool')
155
+ expect(JSON.parse(toolMessage.content)).toEqual({
156
+ answers: [
157
+ { question: '目标用户是谁?', answer: '开发者' },
158
+ { question: '偏好什么风格?', answer: '简洁' },
159
+ { question: '还有什么补充?', answer: '需要深色模式' }
160
+ ]
161
+ })
162
+
163
+ // 问答摘要视图:命中选项的回答渲染为 chip,完成后保持展开
164
+ await vi.waitFor(() => {
165
+ expect(host.querySelectorAll('.u-ai-chat__ask-question-result').length).toBe(3)
166
+ })
167
+ const summary = host.querySelector('.u-ai-chat__ask-question')!.textContent!
168
+ expect(summary).toContain('目标用户是谁?')
169
+ expect(summary).toContain('需要深色模式')
170
+ expect(host.querySelector('.u-ai-chat__ask-question-chip')?.textContent).toBe('开发者')
171
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
172
+ unmount()
173
+ })
174
+
175
+ it('作答中停止生成,显示已取消', async () => {
176
+ const transport: ChatTransport = (_req, handlers) => {
177
+ handlers.onToolCall?.({
178
+ id: 'q1',
179
+ name: 'askQuestion',
180
+ arguments: JSON.stringify({ questions: [{ question: 'Q?' }] })
181
+ })
182
+ }
183
+ const { host, chat, unmount } = mountAiChat(transport)
184
+
185
+ chat.value?.send('提个问')
186
+
187
+ await vi.waitFor(() => {
188
+ expect(host.querySelector('.u-ai-chat__ask-question')).toBeTruthy()
189
+ })
190
+
191
+ chat.value?.abort()
192
+
193
+ await vi.waitFor(() => {
194
+ expect(host.querySelector('.u-ai-chat__ask-question-status')?.textContent).toBe('已取消')
195
+ })
196
+ unmount()
197
+ })
198
+ })
@@ -0,0 +1,114 @@
1
+ import { QuestionFilled } from '@veltra/icons/normal'
2
+ import type { Component } from 'vue'
3
+
4
+ import type { ChatTool } from '../../chat/types'
5
+ import AskQuestion from '../../components/ai-chat/ask-question.vue'
6
+ import { deferAskQuestion, takeAskQuestionDeferred } from './deferred'
7
+
8
+ /** 单个提问项 */
9
+ export interface AskQuestionItem {
10
+ /** 问题文案 */
11
+ question: string
12
+ /** 预设选项(单选);缺省为纯文本题 */
13
+ options?: string[]
14
+ /** 自定义输入占位文案 */
15
+ placeholder?: string
16
+ }
17
+
18
+ /** 一条问答结果 */
19
+ export interface AskQuestionAnswer {
20
+ /** 问题文案 */
21
+ question: string
22
+ /** 用户回答(选中的选项或自定义输入) */
23
+ answer: string
24
+ }
25
+
26
+ /** 提问工具参数(模型输出) */
27
+ export interface AskQuestionArgs {
28
+ questions: AskQuestionItem[]
29
+ }
30
+
31
+ /** 提问工具结果(序列化后回灌模型,渲染层据此展示问答摘要) */
32
+ export interface AskQuestionResult {
33
+ answers: AskQuestionAnswer[]
34
+ }
35
+
36
+ export interface CreateAskQuestionToolOptions {
37
+ /** 工具名(传给模型),默认 askQuestion */
38
+ name?: string
39
+ /** 工具描述(传给模型) */
40
+ description?: string
41
+ /** 工具显示名,默认「提问」 */
42
+ label?: string
43
+ /** 工具图标,默认 QuestionFilled */
44
+ icon?: Component
45
+ }
46
+
47
+ const DEFAULT_DESCRIPTION =
48
+ '当需求不明确或存在歧义时,向用户提问以澄清。一次可提 1-4 个关键问题;' +
49
+ '每个问题可提供若干预设选项,用户可选择选项或自定义输入。用户作答后再继续生成。'
50
+
51
+ /** 提问参数 JSON Schema(原样传给模型) */
52
+ const QUESTIONS_SCHEMA: Record<string, unknown> = {
53
+ type: 'object',
54
+ properties: {
55
+ questions: {
56
+ type: 'array',
57
+ description: '问题列表',
58
+ minItems: 1,
59
+ maxItems: 4,
60
+ items: {
61
+ type: 'object',
62
+ properties: {
63
+ question: { type: 'string', description: '问题文案' },
64
+ options: {
65
+ type: 'array',
66
+ description: '预设选项(单选);缺省为纯文本题',
67
+ items: { type: 'string' }
68
+ },
69
+ placeholder: { type: 'string', description: '自定义输入占位文案' }
70
+ },
71
+ required: ['question']
72
+ }
73
+ }
74
+ },
75
+ required: ['questions']
76
+ }
77
+
78
+ /**
79
+ * 创建内置提问工具:execute 挂起等待用户在 UI 中作答,提交后结果回灌模型。
80
+ * 工具卡片由内联提问表单渲染(多题分页导航,提交后展示问答摘要,不自动折叠)。
81
+ */
82
+ export function createAskQuestionTool(
83
+ options: CreateAskQuestionToolOptions = {}
84
+ ): ChatTool<AskQuestionArgs> {
85
+ const {
86
+ name = 'askQuestion',
87
+ description = DEFAULT_DESCRIPTION,
88
+ label = '提问',
89
+ icon = QuestionFilled
90
+ } = options
91
+
92
+ return {
93
+ name,
94
+ description,
95
+ parameters: QUESTIONS_SCHEMA,
96
+ icon,
97
+ label,
98
+ render: AskQuestion,
99
+ execute: (_args, { toolCall, signal }) =>
100
+ new Promise<AskQuestionResult>((resolve, reject) => {
101
+ deferAskQuestion(toolCall.id, { resolve, reject })
102
+ signal.addEventListener(
103
+ 'abort',
104
+ () => {
105
+ // 不依赖 signal.reason(各环境 DOMException 行为不一),统一拒绝为 AbortError
106
+ const error = new Error('Aborted')
107
+ error.name = 'AbortError'
108
+ takeAskQuestionDeferred(toolCall.id)?.reject(error)
109
+ },
110
+ { once: true }
111
+ )
112
+ })
113
+ }
114
+ }
@@ -0,0 +1,27 @@
1
+ import type { AskQuestionResult } from './ask-question'
2
+
3
+ interface AskQuestionDeferred {
4
+ resolve: (result: AskQuestionResult) => void
5
+ reject: (reason?: unknown) => void
6
+ }
7
+
8
+ /** 挂起的提问 Promise,key 为 toolCallId */
9
+ const pending = new Map<string, AskQuestionDeferred>()
10
+
11
+ /** 注册挂起的提问 Promise(由 createAskQuestionTool 的 execute 调用) */
12
+ export function deferAskQuestion(toolCallId: string, deferred: AskQuestionDeferred): void {
13
+ pending.set(toolCallId, deferred)
14
+ }
15
+
16
+ /** 取出并移除挂起项(abort 监听用);不存在则返回 undefined */
17
+ export function takeAskQuestionDeferred(toolCallId: string): AskQuestionDeferred | undefined {
18
+ const deferred = pending.get(toolCallId)
19
+ if (!deferred) return undefined
20
+ pending.delete(toolCallId)
21
+ return deferred
22
+ }
23
+
24
+ /** 兑现挂起的提问 Promise(由提问表单在用户提交时调用) */
25
+ export function resolveAskQuestion(toolCallId: string, result: AskQuestionResult): void {
26
+ takeAskQuestionDeferred(toolCallId)?.resolve(result)
27
+ }
@@ -0,0 +1 @@
1
+ export * from './ask-question'
@@ -0,0 +1 @@
1
+ export * from './ask-question'
@@ -0,0 +1,56 @@
1
+ import type { DeconstructValue } from '@veltra/utils'
2
+
3
+ import type {
4
+ ChatAttachment,
5
+ ChatMessage,
6
+ ChatTool,
7
+ ChatToolCall,
8
+ ChatTransport
9
+ } from '../chat/types'
10
+
11
+ export interface AiChatProps {
12
+ /** 传输层(必填),可使用导出的 createOpenAITransport() 创建 */
13
+ transport: ChatTransport
14
+ /** 工具定义列表,不同场景传入不同工具赋予助手不同能力 */
15
+ tools?: ChatTool[]
16
+ /** 系统提示词 */
17
+ systemPrompt?: string
18
+ /** 消息列表,支持 v-model:messages 受控 */
19
+ messages?: ChatMessage[]
20
+ /** 空状态欢迎语 */
21
+ welcome?: string
22
+ /** 输入框占位文本 */
23
+ placeholder?: string
24
+ /** 附件 accept 类型,默认 image/* */
25
+ accept?: string
26
+ /** 单个附件最大字节数,默认 10MB */
27
+ maxAttachmentSize?: number
28
+ /** 透传给内部 MarkdownRender 的属性 */
29
+ rendererProps?: Record<string, unknown>
30
+ }
31
+
32
+ export interface AiChatEmits {
33
+ /** 消息列表变化 */
34
+ (e: 'update:messages', messages: ChatMessage[]): void
35
+ /** 用户发送消息 */
36
+ (e: 'send', message: ChatMessage): void
37
+ /** 一轮对话完成(无更多工具调用) */
38
+ (e: 'finish', message: ChatMessage): void
39
+ /** 对话出错 */
40
+ (e: 'error', error: Error): void
41
+ /** 模型发起工具调用 */
42
+ (e: 'tool-call', toolCall: ChatToolCall): void
43
+ }
44
+
45
+ export interface _AiChatExposed {
46
+ /** 发送一条用户消息 */
47
+ send: (content: string, attachments?: ChatAttachment[]) => void
48
+ /** 中断当前生成 */
49
+ abort: () => void
50
+ /** 重新生成最后一条 assistant 回复 */
51
+ regenerate: () => void
52
+ /** 清空消息 */
53
+ clear: () => void
54
+ }
55
+
56
+ export type AiChatExposed = DeconstructValue<_AiChatExposed>
@@ -0,0 +1 @@
1
+ export * from './ai-chat'