@veltra/ai 1.4.0 → 2.0.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.
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/transports/index.d.ts +2 -2
- package/dist/chat/transports/openai.d.ts +16 -12
- package/dist/chat/transports/openai.js +55 -14
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +4 -0
- package/dist/chat/use-chat.d.ts +3 -0
- package/dist/chat/use-chat.js +49 -5
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +27 -7
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js +2 -2
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +50 -38
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +2 -2
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +1 -1
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js +117 -0
- package/dist/components/ai-chat/model-picker.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +2 -0
- package/dist/components/ai-chat/style.js +2 -0
- package/dist/components/ai-chat/style2.css +128 -0
- package/dist/components/ai-chat/tool-call.js +2 -2
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1 -2
- package/dist/providers/types.d.ts +47 -0
- package/dist/tools/ask-question/ask-question.d.ts +2 -17
- package/dist/tools/ask-question/index.d.ts +2 -2
- package/dist/tools/index.d.ts +3 -2
- package/dist/tools/index.js +10 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types/ai-chat.d.ts +14 -0
- package/package.json +8 -8
- package/src/chat/__test__/use-chat.test.ts +71 -0
- package/src/chat/transports/__test__/openai.test.ts +239 -0
- package/src/chat/transports/openai.ts +86 -24
- package/src/chat/types.ts +4 -0
- package/src/chat/use-chat.ts +67 -7
- package/src/components/ai-chat/__test__/ai-chat.test.ts +102 -1
- package/src/components/ai-chat/ai-chat.vue +22 -8
- package/src/components/ai-chat/chat-input.vue +43 -26
- package/src/components/ai-chat/model-picker.vue +126 -0
- package/src/components/ai-chat/style.scss +125 -0
- package/src/components/ai-chat/style.ts +2 -0
- package/src/index.ts +8 -1
- package/src/providers/index.ts +1 -0
- package/src/providers/types.ts +46 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +3 -3
- package/src/tools/index.ts +14 -1
- package/src/types/ai-chat.ts +14 -0
- package/dist/components/ai-chat/ai-chat.vue.d.ts +0 -36
- package/dist/components/ai-chat/index.d.ts +0 -2
- package/dist/components/index.d.ts +0 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ChatModelOption, ChatProvider } from '../../providers'
|
|
1
2
|
import type {
|
|
2
3
|
ChatMessage,
|
|
3
4
|
ChatTool,
|
|
@@ -6,20 +7,24 @@ import type {
|
|
|
6
7
|
ChatTransportRequest
|
|
7
8
|
} from '../types'
|
|
8
9
|
|
|
9
|
-
/** createOpenAITransport
|
|
10
|
+
/** createOpenAITransport 的配置项(多 Provider) */
|
|
10
11
|
export interface OpenAITransportOptions {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
apiKey?: string
|
|
15
|
-
/** 模型名 */
|
|
16
|
-
model: string
|
|
17
|
-
/** 额外请求头 */
|
|
12
|
+
/** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */
|
|
13
|
+
providers: ChatProvider[]
|
|
14
|
+
/** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */
|
|
18
15
|
headers?: Record<string, string>
|
|
19
|
-
/**
|
|
16
|
+
/** 全局额外请求体字段,如 temperature、top_p */
|
|
20
17
|
body?: Record<string, unknown>
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
/** 带扁平模型列表元数据的 OpenAI 兼容 transport */
|
|
21
|
+
export type OpenAITransport = ChatTransport & {
|
|
22
|
+
/** 供 UI 使用的扁平模型列表 */
|
|
23
|
+
readonly models: ChatModelOption[]
|
|
24
|
+
/** 默认模型 id(首个 Provider 的首个模型) */
|
|
25
|
+
readonly defaultModel: string
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
interface OpenAIMessage {
|
|
24
29
|
role: string
|
|
25
30
|
content?: unknown
|
|
@@ -178,33 +183,88 @@ async function parseSSE(
|
|
|
178
183
|
flushToolCalls()
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
/** 校验 providers 并展开扁平模型列表 */
|
|
187
|
+
function flattenModels(providers: ChatProvider[]): {
|
|
188
|
+
models: ChatModelOption[]
|
|
189
|
+
byModelId: Map<string, ChatProvider>
|
|
190
|
+
} {
|
|
191
|
+
if (!providers.length) {
|
|
192
|
+
throw new Error('[createOpenAITransport] providers 不能为空')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const models: ChatModelOption[] = []
|
|
196
|
+
const byModelId = new Map<string, ChatProvider>()
|
|
197
|
+
|
|
198
|
+
for (const provider of providers) {
|
|
199
|
+
if (!provider.models?.length) {
|
|
200
|
+
throw new Error(`[createOpenAITransport] Provider "${provider.id}" 未配置 models`)
|
|
201
|
+
}
|
|
202
|
+
for (const model of provider.models) {
|
|
203
|
+
if (byModelId.has(model.id)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[createOpenAITransport] 模型 id "${model.id}" 重复(跨 Provider 须全局唯一)`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
byModelId.set(model.id, provider)
|
|
209
|
+
models.push({ ...model, providerId: provider.id, providerLabel: provider.label })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { models, byModelId }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 缺省:写入 OpenAI 兼容的 reasoning_effort */
|
|
217
|
+
function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
|
|
218
|
+
body.reasoning_effort = level
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
/**
|
|
182
222
|
* 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
|
|
183
|
-
*
|
|
223
|
+
* 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。
|
|
184
224
|
*/
|
|
185
|
-
export function createOpenAITransport(options: OpenAITransportOptions):
|
|
186
|
-
const {
|
|
225
|
+
export function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {
|
|
226
|
+
const { providers, headers: globalHeaders, body: globalBody } = options
|
|
227
|
+
const { models, byModelId } = flattenModels(providers)
|
|
228
|
+
const defaultModel = models[0]!.id
|
|
187
229
|
|
|
188
|
-
|
|
189
|
-
|
|
230
|
+
const send: ChatTransport = async (
|
|
231
|
+
request: ChatTransportRequest,
|
|
232
|
+
handlers: ChatTransportHandlers
|
|
233
|
+
) => {
|
|
234
|
+
const { messages, systemPrompt, tools, signal, reasoningLevel } = request
|
|
235
|
+
const modelId = request.model ?? defaultModel
|
|
236
|
+
const provider = byModelId.get(modelId)
|
|
237
|
+
|
|
238
|
+
if (!provider) {
|
|
239
|
+
handlers.onError?.(new Error(`未找到模型 "${modelId}" 对应的 Provider`))
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const requestBody: Record<string, unknown> = {
|
|
244
|
+
...globalBody,
|
|
245
|
+
model: modelId,
|
|
246
|
+
stream: true,
|
|
247
|
+
messages: toOpenAIMessages(messages, systemPrompt),
|
|
248
|
+
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (reasoningLevel) {
|
|
252
|
+
const apply = provider.applyReasoning ?? defaultApplyReasoning
|
|
253
|
+
apply(reasoningLevel, requestBody)
|
|
254
|
+
}
|
|
190
255
|
|
|
191
256
|
let response: Response
|
|
192
257
|
try {
|
|
193
|
-
response = await fetch(endpoint, {
|
|
258
|
+
response = await fetch(provider.endpoint, {
|
|
194
259
|
method: 'POST',
|
|
195
260
|
signal,
|
|
196
261
|
headers: {
|
|
197
262
|
'Content-Type': 'application/json',
|
|
198
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
199
|
-
...
|
|
263
|
+
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),
|
|
264
|
+
...globalHeaders,
|
|
265
|
+
...provider.headers
|
|
200
266
|
},
|
|
201
|
-
body: JSON.stringify(
|
|
202
|
-
model,
|
|
203
|
-
stream: true,
|
|
204
|
-
...body,
|
|
205
|
-
messages: toOpenAIMessages(messages, systemPrompt),
|
|
206
|
-
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
207
|
-
})
|
|
267
|
+
body: JSON.stringify(requestBody)
|
|
208
268
|
})
|
|
209
269
|
} catch (error) {
|
|
210
270
|
if (!signal.aborted) {
|
|
@@ -234,4 +294,6 @@ export function createOpenAITransport(options: OpenAITransportOptions): ChatTran
|
|
|
234
294
|
}
|
|
235
295
|
}
|
|
236
296
|
}
|
|
297
|
+
|
|
298
|
+
return Object.assign(send, { models, defaultModel })
|
|
237
299
|
}
|
package/src/chat/types.ts
CHANGED
|
@@ -107,6 +107,10 @@ export interface ChatTransportRequest {
|
|
|
107
107
|
systemPrompt?: string
|
|
108
108
|
/** 可用工具 */
|
|
109
109
|
tools?: ChatTool[]
|
|
110
|
+
/** 选中的模型 id(内置 OpenAI transport 按此路由 Provider) */
|
|
111
|
+
model?: string
|
|
112
|
+
/** 选中的推理等级(不透明字符串,由 Provider.applyReasoning 写入请求体) */
|
|
113
|
+
reasoningLevel?: string
|
|
110
114
|
/** 中断信号 */
|
|
111
115
|
signal: AbortSignal
|
|
112
116
|
}
|
package/src/chat/use-chat.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useModel } from '@veltra/compositions'
|
|
2
|
-
import { reactive, ref, type Ref } from 'vue'
|
|
2
|
+
import { reactive, ref, watch, type Ref } from 'vue'
|
|
3
3
|
|
|
4
|
+
import { createBuiltinTools } from '../tools'
|
|
4
5
|
import type { AiChatEmits, AiChatProps } from '../types'
|
|
5
|
-
import type { ChatAttachment, ChatMessage, ChatToolCall } from './types'
|
|
6
|
+
import type { ChatAttachment, ChatMessage, ChatTool, ChatToolCall } from './types'
|
|
6
7
|
|
|
7
8
|
export interface UseChatOptions {
|
|
8
9
|
props: AiChatProps
|
|
@@ -24,9 +25,17 @@ function serializeResult(result: unknown): string {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** 内置工具 + 用户工具,同名时内置优先 */
|
|
29
|
+
function resolveTools(userTools?: ChatTool[]): ChatTool[] {
|
|
30
|
+
const builtins = createBuiltinTools()
|
|
31
|
+
const names = new Set(builtins.map((t) => t.name))
|
|
32
|
+
return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
|
|
29
37
|
* 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
|
|
38
|
+
* 始终注入内置工具(如 askQuestion),无需经 props 传入。
|
|
30
39
|
*/
|
|
31
40
|
export function useChat(options: UseChatOptions) {
|
|
32
41
|
const { props, emit } = options
|
|
@@ -39,6 +48,12 @@ export function useChat(options: UseChatOptions) {
|
|
|
39
48
|
defaultValue: []
|
|
40
49
|
}) as Ref<ChatMessage[]>
|
|
41
50
|
|
|
51
|
+
const model = useModel({ props, propName: 'model', emit, local: true }) as Ref<string | undefined>
|
|
52
|
+
|
|
53
|
+
const reasoningLevel = useModel({ props, propName: 'reasoningLevel', emit, local: true }) as Ref<
|
|
54
|
+
string | undefined
|
|
55
|
+
>
|
|
56
|
+
|
|
42
57
|
/** 是否正在生成中(含工具执行与多轮循环) */
|
|
43
58
|
const running = ref(false)
|
|
44
59
|
|
|
@@ -46,6 +61,34 @@ export function useChat(options: UseChatOptions) {
|
|
|
46
61
|
/** needsConfirm 工具的挂起确认器,key 为 toolCallId */
|
|
47
62
|
const confirmResolvers = new Map<string, (approved: boolean) => void>()
|
|
48
63
|
|
|
64
|
+
/** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */
|
|
65
|
+
const syncReasoningForModel = (modelId: string | undefined) => {
|
|
66
|
+
const option = props.models?.find((m) => m.id === modelId)
|
|
67
|
+
if (!option?.reasoningLevels?.length) {
|
|
68
|
+
if (reasoningLevel.value !== undefined) {
|
|
69
|
+
reasoningLevel.value = undefined
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const levels = option.reasoningLevels
|
|
74
|
+
if (reasoningLevel.value && levels.some((l) => l.value === reasoningLevel.value)) return
|
|
75
|
+
reasoningLevel.value = option.defaultReasoningLevel ?? levels[0]?.value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// models / model 变化时:确保有合法选中模型,并校正推理等级
|
|
79
|
+
watch(
|
|
80
|
+
() => [props.models, model.value] as const,
|
|
81
|
+
([models, current]) => {
|
|
82
|
+
if (!models?.length) return
|
|
83
|
+
if (!current || !models.some((m) => m.id === current)) {
|
|
84
|
+
model.value = models[0]!.id
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
syncReasoningForModel(current)
|
|
88
|
+
},
|
|
89
|
+
{ immediate: true }
|
|
90
|
+
)
|
|
91
|
+
|
|
49
92
|
/** 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步) */
|
|
50
93
|
const snapshot = () => {
|
|
51
94
|
emit('update:messages', messages.value.slice())
|
|
@@ -65,14 +108,18 @@ export function useChat(options: UseChatOptions) {
|
|
|
65
108
|
}
|
|
66
109
|
|
|
67
110
|
/** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */
|
|
68
|
-
const executeToolCall = async (
|
|
111
|
+
const executeToolCall = async (
|
|
112
|
+
toolCall: ChatToolCall,
|
|
113
|
+
signal: AbortSignal,
|
|
114
|
+
tools: ChatTool[]
|
|
115
|
+
) => {
|
|
69
116
|
const appendToolMessage = (content: string) => {
|
|
70
117
|
messages.value.push(
|
|
71
118
|
reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })
|
|
72
119
|
)
|
|
73
120
|
}
|
|
74
121
|
|
|
75
|
-
const tool =
|
|
122
|
+
const tool = tools.find((t) => t.name === toolCall.name)
|
|
76
123
|
|
|
77
124
|
if (!tool) {
|
|
78
125
|
toolCall.status = 'error'
|
|
@@ -131,6 +178,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
131
178
|
const runRound = async (controller: AbortController): Promise<void> => {
|
|
132
179
|
if (!props.transport) return
|
|
133
180
|
const { signal } = controller
|
|
181
|
+
const tools = resolveTools(props.tools)
|
|
134
182
|
|
|
135
183
|
const assistant = reactive<ChatMessage>({
|
|
136
184
|
id: uid(),
|
|
@@ -149,7 +197,9 @@ export function useChat(options: UseChatOptions) {
|
|
|
149
197
|
// 排除刚追加的 assistant 占位消息
|
|
150
198
|
messages: messages.value.slice(0, -1),
|
|
151
199
|
systemPrompt: props.systemPrompt,
|
|
152
|
-
tools
|
|
200
|
+
tools,
|
|
201
|
+
model: model.value,
|
|
202
|
+
reasoningLevel: reasoningLevel.value,
|
|
153
203
|
signal
|
|
154
204
|
},
|
|
155
205
|
{
|
|
@@ -195,7 +245,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
195
245
|
// 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop
|
|
196
246
|
await toolCalls.reduce<Promise<void>>(
|
|
197
247
|
(prev, toolCall) =>
|
|
198
|
-
prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal))),
|
|
248
|
+
prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal, tools))),
|
|
199
249
|
Promise.resolve()
|
|
200
250
|
)
|
|
201
251
|
snapshot()
|
|
@@ -241,5 +291,15 @@ export function useChat(options: UseChatOptions) {
|
|
|
241
291
|
snapshot()
|
|
242
292
|
}
|
|
243
293
|
|
|
244
|
-
return {
|
|
294
|
+
return {
|
|
295
|
+
messages,
|
|
296
|
+
model,
|
|
297
|
+
reasoningLevel,
|
|
298
|
+
running,
|
|
299
|
+
send,
|
|
300
|
+
abort,
|
|
301
|
+
regenerate,
|
|
302
|
+
clear,
|
|
303
|
+
respondToolCall
|
|
304
|
+
}
|
|
245
305
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
|
|
3
3
|
|
|
4
4
|
import type { ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
|
|
5
|
+
import type { ChatModelOption } from '../../../providers'
|
|
5
6
|
import type { AiChatExposed } from '../../../types'
|
|
6
7
|
import UAiChat from '../ai-chat.vue'
|
|
7
8
|
|
|
@@ -19,6 +20,9 @@ function mountAiChat(options: {
|
|
|
19
20
|
transport: ChatTransport
|
|
20
21
|
tools?: ChatTool[]
|
|
21
22
|
welcome?: string
|
|
23
|
+
models?: ChatModelOption[]
|
|
24
|
+
model?: string
|
|
25
|
+
reasoningLevel?: string
|
|
22
26
|
slots?: Record<string, (scope: any) => any>
|
|
23
27
|
}) {
|
|
24
28
|
const host = document.createElement('div')
|
|
@@ -34,7 +38,10 @@ function mountAiChat(options: {
|
|
|
34
38
|
ref: chatRef,
|
|
35
39
|
transport: options.transport,
|
|
36
40
|
tools: options.tools,
|
|
37
|
-
welcome: options.welcome
|
|
41
|
+
welcome: options.welcome,
|
|
42
|
+
models: options.models,
|
|
43
|
+
model: options.model,
|
|
44
|
+
reasoningLevel: options.reasoningLevel
|
|
38
45
|
},
|
|
39
46
|
options.slots
|
|
40
47
|
)
|
|
@@ -294,6 +301,14 @@ describe('UAiChat', () => {
|
|
|
294
301
|
unmount()
|
|
295
302
|
})
|
|
296
303
|
|
|
304
|
+
it('输入区使用原生 textarea 与 UFilePicker', () => {
|
|
305
|
+
const { host, unmount } = mountAiChat({ transport: () => {} })
|
|
306
|
+
|
|
307
|
+
expect(host.querySelector('textarea.u-ai-chat__input')).toBeTruthy()
|
|
308
|
+
expect(host.querySelector('.u-ai-chat__input-toolbar-left .u-file-picker')).toBeTruthy()
|
|
309
|
+
unmount()
|
|
310
|
+
})
|
|
311
|
+
|
|
297
312
|
it('生成中输入区显示停止按钮', async () => {
|
|
298
313
|
const transport: ChatTransport = (req) => {
|
|
299
314
|
return new Promise<void>((resolve) => {
|
|
@@ -313,4 +328,90 @@ describe('UAiChat', () => {
|
|
|
313
328
|
})
|
|
314
329
|
unmount()
|
|
315
330
|
})
|
|
331
|
+
|
|
332
|
+
it('有 models 时渲染模型选择器;当前模型无 reasoningLevels 时不展示推理等级', async () => {
|
|
333
|
+
const { host, unmount } = mountAiChat({
|
|
334
|
+
transport: () => {},
|
|
335
|
+
models: [
|
|
336
|
+
{ id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai' },
|
|
337
|
+
{
|
|
338
|
+
id: 'o3-mini',
|
|
339
|
+
label: 'o3-mini',
|
|
340
|
+
providerId: 'openai',
|
|
341
|
+
reasoningLevels: [
|
|
342
|
+
{ value: 'low', label: '低' },
|
|
343
|
+
{ value: 'high', label: '高' }
|
|
344
|
+
],
|
|
345
|
+
defaultReasoningLevel: 'low'
|
|
346
|
+
}
|
|
347
|
+
],
|
|
348
|
+
model: 'gpt-4o'
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
await nextTick()
|
|
352
|
+
// 模型选择器在右簇(发送按钮左侧),不在附件侧
|
|
353
|
+
expect(
|
|
354
|
+
host.querySelector('.u-ai-chat__input-toolbar-right .u-ai-chat__model-trigger')
|
|
355
|
+
).toBeTruthy()
|
|
356
|
+
expect(
|
|
357
|
+
host.querySelector('.u-ai-chat__input-toolbar-left .u-ai-chat__model-trigger')
|
|
358
|
+
).toBeFalsy()
|
|
359
|
+
// 当前模型无 reasoningLevels,触发器不展示推理等级
|
|
360
|
+
expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
|
|
361
|
+
unmount()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('切换到带 reasoningLevels 的模型后触发器展示推理等级', async () => {
|
|
365
|
+
const model = ref('gpt-4o')
|
|
366
|
+
const reasoningLevel = ref<string | undefined>()
|
|
367
|
+
const host = document.createElement('div')
|
|
368
|
+
document.body.appendChild(host)
|
|
369
|
+
|
|
370
|
+
const app = createApp({
|
|
371
|
+
setup() {
|
|
372
|
+
return () =>
|
|
373
|
+
h(UAiChat, {
|
|
374
|
+
transport: () => {},
|
|
375
|
+
models: [
|
|
376
|
+
{ id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai' },
|
|
377
|
+
{
|
|
378
|
+
id: 'o3-mini',
|
|
379
|
+
label: 'o3-mini',
|
|
380
|
+
providerId: 'openai',
|
|
381
|
+
reasoningLevels: [
|
|
382
|
+
{ value: 'low', label: '低' },
|
|
383
|
+
{ value: 'high', label: '高' }
|
|
384
|
+
],
|
|
385
|
+
defaultReasoningLevel: 'low'
|
|
386
|
+
}
|
|
387
|
+
],
|
|
388
|
+
model: model.value,
|
|
389
|
+
'onUpdate:model': (v: string | undefined) => {
|
|
390
|
+
model.value = v ?? ''
|
|
391
|
+
},
|
|
392
|
+
reasoningLevel: reasoningLevel.value,
|
|
393
|
+
'onUpdate:reasoningLevel': (v: string | undefined) => {
|
|
394
|
+
reasoningLevel.value = v
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
app.mount(host)
|
|
400
|
+
await nextTick()
|
|
401
|
+
|
|
402
|
+
expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
|
|
403
|
+
|
|
404
|
+
model.value = 'o3-mini'
|
|
405
|
+
await nextTick()
|
|
406
|
+
|
|
407
|
+
const reasoning = host.querySelector(
|
|
408
|
+
'.u-ai-chat__input-toolbar-right .u-ai-chat__model-trigger-reasoning'
|
|
409
|
+
)
|
|
410
|
+
expect(reasoning).toBeTruthy()
|
|
411
|
+
expect(reasoning?.textContent).toBe('低')
|
|
412
|
+
expect(reasoningLevel.value).toBe('low')
|
|
413
|
+
|
|
414
|
+
app.unmount()
|
|
415
|
+
host.remove()
|
|
416
|
+
})
|
|
316
417
|
})
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
</MessageList>
|
|
14
14
|
|
|
15
15
|
<ChatInput
|
|
16
|
+
v-model:model="model"
|
|
17
|
+
v-model:reasoning-level="reasoningLevel"
|
|
16
18
|
:running="running"
|
|
19
|
+
:models="models"
|
|
17
20
|
:placeholder="placeholder"
|
|
18
21
|
:accept="accept"
|
|
19
22
|
:max-attachment-size="maxAttachmentSize"
|
|
@@ -27,8 +30,9 @@
|
|
|
27
30
|
import { bem } from '@veltra/utils'
|
|
28
31
|
import { computed, provide, useSlots } from 'vue'
|
|
29
32
|
|
|
30
|
-
import type {
|
|
33
|
+
import type { ChatToolCall } from '../../chat/types'
|
|
31
34
|
import { useChat } from '../../chat/use-chat'
|
|
35
|
+
import { createBuiltinTools } from '../../tools'
|
|
32
36
|
import type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'
|
|
33
37
|
import ChatInput from './chat-input.vue'
|
|
34
38
|
import { AiChatDIKey } from './di'
|
|
@@ -51,17 +55,27 @@ const cls = bem('ai-chat')
|
|
|
51
55
|
|
|
52
56
|
const slots = useSlots()
|
|
53
57
|
|
|
54
|
-
/**
|
|
55
|
-
const toolMap = computed
|
|
56
|
-
|
|
58
|
+
/** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/autoCollapse */
|
|
59
|
+
const toolMap = computed(() => {
|
|
60
|
+
const builtins = createBuiltinTools()
|
|
61
|
+
const names = new Set(builtins.map((t) => t.name))
|
|
62
|
+
const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]
|
|
63
|
+
return Object.fromEntries(tools.map((t) => [t.name, t]))
|
|
57
64
|
})
|
|
58
65
|
|
|
59
66
|
provide(AiChatDIKey, { cls, slots, tools: toolMap })
|
|
60
67
|
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
const {
|
|
69
|
+
messages,
|
|
70
|
+
model,
|
|
71
|
+
reasoningLevel,
|
|
72
|
+
running,
|
|
73
|
+
send,
|
|
74
|
+
abort,
|
|
75
|
+
regenerate,
|
|
76
|
+
clear,
|
|
77
|
+
respondToolCall
|
|
78
|
+
} = useChat({ props, emit })
|
|
65
79
|
|
|
66
80
|
defineExpose<_AiChatExposed>({ send, abort, regenerate, clear })
|
|
67
81
|
</script>
|
|
@@ -24,42 +24,59 @@
|
|
|
24
24
|
/>
|
|
25
25
|
|
|
26
26
|
<div :class="cls.e('input-toolbar')">
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
<div :class="cls.e('input-toolbar-left')">
|
|
28
|
+
<UFilePicker multiple :accept="accept ?? 'image/*'" @pick="handlePick">
|
|
29
|
+
<UIcon :class="cls.e('input-attach')" title="添加图片">
|
|
30
|
+
<Attach />
|
|
31
|
+
</UIcon>
|
|
32
|
+
</UFilePicker>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- 右簇:模型/推理选择 → 发送/停止,贴近发送按钮 -->
|
|
36
|
+
<div :class="cls.e('input-toolbar-right')">
|
|
37
|
+
<ModelPicker
|
|
38
|
+
v-if="models?.length"
|
|
39
|
+
v-model:model="model"
|
|
40
|
+
v-model:reasoning-level="reasoningLevel"
|
|
41
|
+
:models="models"
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<UButton v-if="running" size="small" type="danger" circle @click="emit('abort')">
|
|
45
|
+
<span :class="cls.e('input-stop')" />
|
|
46
|
+
</UButton>
|
|
47
|
+
<UButton
|
|
48
|
+
v-else
|
|
49
|
+
size="small"
|
|
50
|
+
type="primary"
|
|
51
|
+
circle
|
|
52
|
+
:disabled="!canSend"
|
|
53
|
+
:icon="Up"
|
|
54
|
+
@click="handleSend"
|
|
55
|
+
>
|
|
56
|
+
</UButton>
|
|
57
|
+
</div>
|
|
45
58
|
</div>
|
|
46
59
|
</div>
|
|
47
60
|
</template>
|
|
48
61
|
|
|
49
62
|
<script lang="ts" setup>
|
|
50
|
-
import { UButton, UIcon } from '@veltra/desktop'
|
|
51
|
-
import { Attach, Close,
|
|
63
|
+
import { UButton, UFilePicker, UIcon } from '@veltra/desktop'
|
|
64
|
+
import { Attach, Close, Up } from '@veltra/icons/normal'
|
|
52
65
|
import { bem } from '@veltra/utils'
|
|
53
66
|
import { computed, inject, ref, shallowRef } from 'vue'
|
|
54
67
|
|
|
55
68
|
import type { ChatAttachment } from '../../chat/types'
|
|
69
|
+
import type { ChatModelOption } from '../../providers'
|
|
56
70
|
import { AiChatDIKey } from './di'
|
|
71
|
+
import ModelPicker from './model-picker.vue'
|
|
57
72
|
|
|
58
73
|
defineOptions({ name: 'UAiChatInput' })
|
|
59
74
|
|
|
60
75
|
const props = defineProps<{
|
|
61
76
|
/** 是否生成中(显示停止按钮) */
|
|
62
77
|
running: boolean
|
|
78
|
+
/** 可选模型列表;有值则显示选择器 */
|
|
79
|
+
models?: ChatModelOption[]
|
|
63
80
|
/** 输入框占位文本 */
|
|
64
81
|
placeholder?: string
|
|
65
82
|
/** 附件 accept 类型 */
|
|
@@ -68,6 +85,9 @@ const props = defineProps<{
|
|
|
68
85
|
maxAttachmentSize?: number
|
|
69
86
|
}>()
|
|
70
87
|
|
|
88
|
+
const model = defineModel<string>('model')
|
|
89
|
+
const reasoningLevel = defineModel<string>('reasoningLevel')
|
|
90
|
+
|
|
71
91
|
const emit = defineEmits<{
|
|
72
92
|
(e: 'send', content: string, attachments: ChatAttachment[]): void
|
|
73
93
|
(e: 'abort'): void
|
|
@@ -79,12 +99,12 @@ const cls = di?.cls ?? bem('ai-chat')
|
|
|
79
99
|
const text = ref('')
|
|
80
100
|
const attachments = ref<ChatAttachment[]>([])
|
|
81
101
|
const textareaRef = shallowRef<HTMLTextAreaElement>()
|
|
82
|
-
const fileRef = shallowRef<HTMLInputElement>()
|
|
83
102
|
|
|
84
103
|
const canSend = computed(() => {
|
|
85
104
|
return !props.running && (!!text.value.trim() || attachments.value.length > 0)
|
|
86
105
|
})
|
|
87
106
|
|
|
107
|
+
/** 多行自适应高度,上限 160px */
|
|
88
108
|
const autoResize = () => {
|
|
89
109
|
const el = textareaRef.value
|
|
90
110
|
if (!el) return
|
|
@@ -100,11 +120,8 @@ const handleSend = () => {
|
|
|
100
120
|
autoResize()
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const files = Array.from(input.files ?? [])
|
|
106
|
-
input.value = ''
|
|
107
|
-
|
|
123
|
+
/** 将拾取的文件转为 dataUrl 附件(超限忽略) */
|
|
124
|
+
const handlePick = (files: File[]) => {
|
|
108
125
|
const maxSize = props.maxAttachmentSize ?? 10 * 1024 * 1024
|
|
109
126
|
|
|
110
127
|
for (const file of files) {
|