@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,127 @@
1
+ <template>
2
+ <div :class="cls.e('input-area')">
3
+ <div v-if="attachments.length" :class="cls.e('input-attachments')">
4
+ <div
5
+ v-for="(att, index) in attachments"
6
+ :key="att.dataUrl"
7
+ :class="cls.e('input-attachment')"
8
+ >
9
+ <img :src="att.dataUrl" :alt="att.name" />
10
+ <UIcon :class="cls.e('input-attachment-remove')" @click="attachments.splice(index, 1)">
11
+ <Close />
12
+ </UIcon>
13
+ </div>
14
+ </div>
15
+
16
+ <textarea
17
+ ref="textareaRef"
18
+ v-model="text"
19
+ :class="cls.e('input')"
20
+ :placeholder="placeholder ?? '输入消息,Enter 发送,Shift + Enter 换行'"
21
+ rows="1"
22
+ @keydown.enter.exact.prevent="handleSend"
23
+ @input="autoResize"
24
+ />
25
+
26
+ <div :class="cls.e('input-toolbar')">
27
+ <UIcon :class="cls.e('input-attach')" title="添加图片" @click="fileRef?.click()">
28
+ <Attach />
29
+ </UIcon>
30
+ <input
31
+ ref="fileRef"
32
+ type="file"
33
+ hidden
34
+ multiple
35
+ :accept="accept ?? 'image/*'"
36
+ @change="handleFiles"
37
+ />
38
+
39
+ <UButton v-if="running" type="danger" circle @click="emit('abort')">
40
+ <span :class="cls.e('input-stop')" />
41
+ </UButton>
42
+ <UButton v-else type="primary" circle :disabled="!canSend" @click="handleSend">
43
+ <UIcon><Send /></UIcon>
44
+ </UButton>
45
+ </div>
46
+ </div>
47
+ </template>
48
+
49
+ <script lang="ts" setup>
50
+ import { UButton, UIcon } from '@veltra/desktop'
51
+ import { Attach, Close, Send } from '@veltra/icons/normal'
52
+ import { bem } from '@veltra/utils'
53
+ import { computed, inject, ref, shallowRef } from 'vue'
54
+
55
+ import type { ChatAttachment } from '../../chat/types'
56
+ import { AiChatDIKey } from './di'
57
+
58
+ defineOptions({ name: 'UAiChatInput' })
59
+
60
+ const props = defineProps<{
61
+ /** 是否生成中(显示停止按钮) */
62
+ running: boolean
63
+ /** 输入框占位文本 */
64
+ placeholder?: string
65
+ /** 附件 accept 类型 */
66
+ accept?: string
67
+ /** 单个附件最大字节数 */
68
+ maxAttachmentSize?: number
69
+ }>()
70
+
71
+ const emit = defineEmits<{
72
+ (e: 'send', content: string, attachments: ChatAttachment[]): void
73
+ (e: 'abort'): void
74
+ }>()
75
+
76
+ const di = inject(AiChatDIKey)
77
+ const cls = di?.cls ?? bem('ai-chat')
78
+
79
+ const text = ref('')
80
+ const attachments = ref<ChatAttachment[]>([])
81
+ const textareaRef = shallowRef<HTMLTextAreaElement>()
82
+ const fileRef = shallowRef<HTMLInputElement>()
83
+
84
+ const canSend = computed(() => {
85
+ return !props.running && (!!text.value.trim() || attachments.value.length > 0)
86
+ })
87
+
88
+ const autoResize = () => {
89
+ const el = textareaRef.value
90
+ if (!el) return
91
+ el.style.height = 'auto'
92
+ el.style.height = `${Math.min(el.scrollHeight, 160)}px`
93
+ }
94
+
95
+ const handleSend = () => {
96
+ if (!canSend.value) return
97
+ emit('send', text.value.trim(), attachments.value)
98
+ text.value = ''
99
+ attachments.value = []
100
+ autoResize()
101
+ }
102
+
103
+ const handleFiles = (event: Event) => {
104
+ const input = event.target as HTMLInputElement
105
+ const files = Array.from(input.files ?? [])
106
+ input.value = ''
107
+
108
+ const maxSize = props.maxAttachmentSize ?? 10 * 1024 * 1024
109
+
110
+ for (const file of files) {
111
+ if (file.size > maxSize) {
112
+ console.warn(`[UAiChat] 附件 ${file.name} 超过大小限制,已忽略`)
113
+ continue
114
+ }
115
+ const reader = new FileReader()
116
+ reader.onload = () => {
117
+ attachments.value.push({
118
+ name: file.name,
119
+ mimeType: file.type,
120
+ size: file.size,
121
+ dataUrl: String(reader.result)
122
+ })
123
+ }
124
+ reader.readAsDataURL(file)
125
+ }
126
+ }
127
+ </script>
@@ -0,0 +1,15 @@
1
+ import type { BEM } from '@veltra/utils'
2
+ import type { ComputedRef, InjectionKey, Slots } from 'vue'
3
+
4
+ import type { ChatTool } from '../../chat/types'
5
+
6
+ export interface AiChatContext {
7
+ /** ai-chat 组件的 BEM 类生成器 */
8
+ cls: BEM<'ai-chat'>
9
+ /** ai-chat 使用方提供的插槽(用于 tool-<name> 动态插槽) */
10
+ slots: Slots
11
+ /** 按 name 索引的工具定义(解析 icon/label/render/autoCollapse) */
12
+ tools: ComputedRef<Record<string, ChatTool | undefined>>
13
+ }
14
+
15
+ export const AiChatDIKey: InjectionKey<AiChatContext> = Symbol('AiChatDIKey')
@@ -0,0 +1 @@
1
+ export { default as UAiChat } from './ai-chat.vue'
@@ -0,0 +1,120 @@
1
+ <template>
2
+ <div
3
+ :class="[cls.e('message'), cls.em('message', message.role), bem.is('streaming', isStreaming)]"
4
+ >
5
+ <!-- 用户消息:右对齐气泡 -->
6
+ <template v-if="message.role === 'user'">
7
+ <div :class="cls.e('message-bubble')">
8
+ <div v-if="message.attachments?.length" :class="cls.e('message-attachments')">
9
+ <img
10
+ v-for="att in message.attachments"
11
+ :key="att.dataUrl"
12
+ :src="att.dataUrl"
13
+ :alt="att.name"
14
+ :class="cls.e('message-attachment')"
15
+ />
16
+ </div>
17
+ <div v-if="message.content" :class="cls.e('message-text')">{{ message.content }}</div>
18
+ </div>
19
+ </template>
20
+
21
+ <!-- assistant 消息 -->
22
+ <template v-else>
23
+ <div
24
+ v-if="message.reasoning"
25
+ :class="[cls.e('reasoning'), bem.is('expanded', reasoningExpanded)]"
26
+ >
27
+ <div :class="cls.e('reasoning-header')" @click="reasoningExpanded = !reasoningExpanded">
28
+ <UIcon :class="[cls.e('reasoning-icon'), isThinking && cls.is('loading')]">
29
+ <Loading v-if="isThinking" />
30
+ <InfoCircle v-else />
31
+ </UIcon>
32
+ <span>{{ isThinking ? '思考中…' : '思考过程' }}</span>
33
+ <UIcon :class="cls.e('reasoning-chevron')"><ArrowDown /></UIcon>
34
+ </div>
35
+ <div v-show="reasoningExpanded" :class="cls.e('reasoning-content')">
36
+ {{ message.reasoning }}
37
+ </div>
38
+ </div>
39
+
40
+ <MarkdownRender
41
+ v-if="message.content || isStreaming"
42
+ mode="chat"
43
+ :class="cls.e('message-content')"
44
+ :content="message.content"
45
+ :final="!isStreaming"
46
+ :fade="false"
47
+ v-bind="rendererProps"
48
+ />
49
+
50
+ <ToolCallCard
51
+ v-for="call in message.toolCalls ?? []"
52
+ :key="call.id"
53
+ :tool-call="call"
54
+ @respond="(approved) => emit('respond', call.id, approved)"
55
+ />
56
+
57
+ <div v-if="message.status === 'error'" :class="cls.e('message-status')">生成出错</div>
58
+ <div v-else-if="message.status === 'aborted'" :class="cls.e('message-status')">
59
+ 已停止生成
60
+ </div>
61
+
62
+ <div v-if="showActions" :class="cls.e('message-actions')" @click="emit('regenerate')">
63
+ <UIcon title="重新生成"><Refresh /></UIcon>
64
+ </div>
65
+ </template>
66
+ </div>
67
+ </template>
68
+
69
+ <script lang="ts" setup>
70
+ import { UIcon } from '@veltra/desktop'
71
+ import { ArrowDown, InfoCircle, Loading, Refresh } from '@veltra/icons/normal'
72
+ import { bem } from '@veltra/utils'
73
+ import MarkdownRender from 'markstream-vue'
74
+ import { computed, inject, ref, watch } from 'vue'
75
+
76
+ import type { ChatMessage } from '../../chat/types'
77
+ import { AiChatDIKey } from './di'
78
+ import ToolCallCard from './tool-call.vue'
79
+
80
+ defineOptions({ name: 'UAiChatMessageItem' })
81
+
82
+ const props = defineProps<{
83
+ message: ChatMessage
84
+ /** 是否为最后一条可见消息(决定是否显示重新生成) */
85
+ isLast: boolean
86
+ /** 透传给 MarkdownRender 的属性 */
87
+ rendererProps?: Record<string, unknown>
88
+ }>()
89
+
90
+ const emit = defineEmits<{
91
+ (e: 'respond', toolCallId: string, approved: boolean): void
92
+ (e: 'regenerate'): void
93
+ }>()
94
+
95
+ const di = inject(AiChatDIKey)
96
+ const cls = di?.cls ?? bem('ai-chat')
97
+
98
+ const isStreaming = computed(() => props.message.status === 'streaming')
99
+ /** 思考中:流式且正文还未开始输出 */
100
+ const isThinking = computed(() => isStreaming.value && !props.message.content)
101
+
102
+ const reasoningExpanded = ref(true)
103
+
104
+ // 流式结束后自动折叠思考过程
105
+ watch(
106
+ () => props.message.status,
107
+ (status) => {
108
+ if (status === 'streaming') {
109
+ reasoningExpanded.value = true
110
+ } else if (status) {
111
+ reasoningExpanded.value = false
112
+ }
113
+ }
114
+ )
115
+
116
+ const showActions = computed(() => {
117
+ if (props.message.role !== 'assistant' || !props.isLast) return false
118
+ return ['done', 'error', 'aborted'].includes(props.message.status ?? '')
119
+ })
120
+ </script>
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <UScroll :class="cls.e('list')" ref="scrollRef" always @scroll="handleScroll">
3
+ <div v-if="!visibleMessages.length" :class="cls.e('welcome')">
4
+ <slot name="welcome">{{ welcome }}</slot>
5
+ </div>
6
+
7
+ <MessageItem
8
+ v-for="(msg, index) in visibleMessages"
9
+ :key="msg.id"
10
+ :message="msg"
11
+ :is-last="index === visibleMessages.length - 1"
12
+ :renderer-props="rendererProps"
13
+ @respond="(id, approved) => emit('respond', id, approved)"
14
+ @regenerate="emit('regenerate')"
15
+ />
16
+ </UScroll>
17
+ </template>
18
+
19
+ <script lang="ts" setup>
20
+ import { UScroll } from '@veltra/desktop'
21
+ import type { ScrollPosition } from '@veltra/desktop'
22
+ import { bem } from '@veltra/utils'
23
+ import { computed, inject, nextTick, ref, shallowRef, watch } from 'vue'
24
+
25
+ import type { ChatMessage } from '../../chat/types'
26
+ import { AiChatDIKey } from './di'
27
+ import MessageItem from './message-item.vue'
28
+
29
+ defineOptions({ name: 'UAiChatMessageList' })
30
+
31
+ const props = defineProps<{
32
+ messages: ChatMessage[]
33
+ /** 空状态欢迎语 */
34
+ welcome?: string
35
+ /** 透传给 MarkdownRender 的属性 */
36
+ rendererProps?: Record<string, unknown>
37
+ }>()
38
+
39
+ const emit = defineEmits<{
40
+ (e: 'respond', toolCallId: string, approved: boolean): void
41
+ (e: 'regenerate'): void
42
+ }>()
43
+
44
+ defineSlots<{
45
+ /** 空状态欢迎区插槽 */
46
+ welcome(): any
47
+ }>()
48
+
49
+ const di = inject(AiChatDIKey)
50
+ const cls = di?.cls ?? bem('ai-chat')
51
+
52
+ /** tool 消息不单独渲染,其内容体现在工具卡片的结果区 */
53
+ const visibleMessages = computed(() => props.messages.filter((msg) => msg.role !== 'tool'))
54
+
55
+ const scrollRef = shallowRef<{ scrollTo: (position: ScrollPosition) => void }>()
56
+ /** 是否吸附底部(用户上翻浏览历史时取消吸附) */
57
+ const stickToBottom = ref(true)
58
+
59
+ const handleScroll = (position: Required<ScrollPosition>) => {
60
+ stickToBottom.value = position.sh - position.ch - position.y < 60
61
+ }
62
+
63
+ // 消息数量或流式内容变化时,若处于吸附状态则滚动到底部
64
+ watch(
65
+ () => {
66
+ const list = visibleMessages.value
67
+ const last = list[list.length - 1]
68
+ return [
69
+ list.length,
70
+ last?.content.length ?? 0,
71
+ last?.reasoning?.length ?? 0,
72
+ last?.toolCalls?.length ?? 0
73
+ ].join('|')
74
+ },
75
+ async () => {
76
+ if (!stickToBottom.value) return
77
+ await nextTick()
78
+ scrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })
79
+ }
80
+ )
81
+ </script>