@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.
Files changed (56) hide show
  1. package/dist/chat/index.d.ts +2 -2
  2. package/dist/chat/transports/index.d.ts +2 -2
  3. package/dist/chat/transports/openai.d.ts +16 -12
  4. package/dist/chat/transports/openai.js +55 -14
  5. package/dist/chat/transports/openai.js.map +1 -1
  6. package/dist/chat/types.d.ts +4 -0
  7. package/dist/chat/use-chat.d.ts +3 -0
  8. package/dist/chat/use-chat.js +49 -5
  9. package/dist/chat/use-chat.js.map +1 -1
  10. package/dist/components/ai-chat/ai-chat.js +27 -7
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  12. package/dist/components/ai-chat/ask-question.js +2 -2
  13. package/dist/components/ai-chat/ask-question.js.map +1 -1
  14. package/dist/components/ai-chat/chat-input.js +50 -38
  15. package/dist/components/ai-chat/chat-input.js.map +1 -1
  16. package/dist/components/ai-chat/message-item.js +2 -2
  17. package/dist/components/ai-chat/message-item.js.map +1 -1
  18. package/dist/components/ai-chat/message-list.js +1 -1
  19. package/dist/components/ai-chat/message-list.js.map +1 -1
  20. package/dist/components/ai-chat/model-picker.js +117 -0
  21. package/dist/components/ai-chat/model-picker.js.map +1 -0
  22. package/dist/components/ai-chat/style.d.ts +2 -0
  23. package/dist/components/ai-chat/style.js +2 -0
  24. package/dist/components/ai-chat/style2.css +128 -0
  25. package/dist/components/ai-chat/tool-call.js +2 -2
  26. package/dist/components/ai-chat/tool-call.js.map +1 -1
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.js +1 -2
  29. package/dist/providers/types.d.ts +47 -0
  30. package/dist/tools/ask-question/ask-question.d.ts +2 -17
  31. package/dist/tools/ask-question/index.d.ts +2 -2
  32. package/dist/tools/index.d.ts +3 -2
  33. package/dist/tools/index.js +10 -0
  34. package/dist/tools/index.js.map +1 -0
  35. package/dist/types/ai-chat.d.ts +14 -0
  36. package/package.json +8 -8
  37. package/src/chat/__test__/use-chat.test.ts +71 -0
  38. package/src/chat/transports/__test__/openai.test.ts +239 -0
  39. package/src/chat/transports/openai.ts +86 -24
  40. package/src/chat/types.ts +4 -0
  41. package/src/chat/use-chat.ts +67 -7
  42. package/src/components/ai-chat/__test__/ai-chat.test.ts +102 -1
  43. package/src/components/ai-chat/ai-chat.vue +22 -8
  44. package/src/components/ai-chat/chat-input.vue +43 -26
  45. package/src/components/ai-chat/model-picker.vue +126 -0
  46. package/src/components/ai-chat/style.scss +125 -0
  47. package/src/components/ai-chat/style.ts +2 -0
  48. package/src/index.ts +8 -1
  49. package/src/providers/index.ts +1 -0
  50. package/src/providers/types.ts +46 -0
  51. package/src/tools/ask-question/__test__/ask-question.test.ts +3 -3
  52. package/src/tools/index.ts +14 -1
  53. package/src/types/ai-chat.ts +14 -0
  54. package/dist/components/ai-chat/ai-chat.vue.d.ts +0 -36
  55. package/dist/components/ai-chat/index.d.ts +0 -2
  56. 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
- /** chat completions 端点,如 https://api.openai.com/v1/chat/completions */
12
- endpoint: string
13
- /** API Key(Bearer 鉴权) */
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
- /** 额外请求体字段,如 temperature、top_p */
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
- * 手写 fetch + SSE 解析,无任何第三方依赖。
223
+ * 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。
184
224
  */
185
- export function createOpenAITransport(options: OpenAITransportOptions): ChatTransport {
186
- const { endpoint, apiKey, model, headers, body } = options
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
- return async (request: ChatTransportRequest, handlers: ChatTransportHandlers) => {
189
- const { messages, systemPrompt, tools, signal } = request
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
- ...headers
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
  }
@@ -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 (toolCall: ChatToolCall, signal: AbortSignal) => {
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 = props.tools?.find((t) => t.name === toolCall.name)
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: props.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 { messages, running, send, abort, regenerate, clear, respondToolCall }
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 { ChatTool, ChatToolCall } from '../../chat/types'
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
- /** 按 name 索引的工具定义,供工具卡片解析 icon/label/render/autoCollapse */
55
- const toolMap = computed<Record<string, ChatTool | undefined>>(() => {
56
- return Object.fromEntries((props.tools ?? []).map((tool) => [tool.name, tool]))
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 { messages, running, send, abort, regenerate, clear, respondToolCall } = useChat({
62
- props,
63
- emit
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
- <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>
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, Send } from '@veltra/icons/normal'
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
- const handleFiles = (event: Event) => {
104
- const input = event.target as HTMLInputElement
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) {