@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 +1 @@
1
- {"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/components/ai-chat/tool-call.vue"],"sourcesContent":["<template>\n <div :class=\"[cls.e('tool-call'), bem.is(toolCall.status), bem.is('expanded', isExpanded)]\">\n <UCollapseItem v-model=\"isExpanded\" @change=\"markUserToggled\">\n <template #title>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"cls.e('tool-call-name')\">{{ tool?.label ?? toolCall.name }}</span>\n <span v-if=\"summary\" :class=\"cls.e('tool-call-summary')\">{{ summary }}</span>\n\n <span\n v-if=\"toolCall.status === 'awaiting-confirm'\"\n :class=\"cls.e('tool-call-confirm')\"\n @click.stop\n >\n <UButton size=\"small\" type=\"primary\" @click=\"emit('respond', true)\">允许</UButton>\n <UButton size=\"small\" text @click=\"emit('respond', false)\">拒绝</UButton>\n </span>\n </template>\n\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool?.render\" v-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <component :is=\"slotBody\" v-else-if=\"slotBody && hasResult\" />\n <template v-else>\n <template v-if=\"prettyArguments\">\n <div :class=\"cls.e('tool-call-section')\">参数</div>\n <pre :class=\"cls.e('tool-call-code')\">{{ prettyArguments }}</pre>\n </template>\n\n <template v-if=\"hasResult\">\n <div :class=\"cls.e('tool-call-section')\">\n {{ toolCall.status === 'error' ? '错误' : '结果' }}\n </div>\n <pre :class=\"cls.e('tool-call-code')\">{{ displayResult }}</pre>\n </template>\n </template>\n </UCollapseItem>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\nimport '@veltra/desktop/components/collapse/style'\nimport { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, ref, watch, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatToolCall' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'respond', approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\nfunction markUserToggled() {\n userToggled = true\n}\n\n/** 完成后是否自动折叠:缺省有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(() => tool.value?.autoCollapse ?? !tool.value?.render)\n\nwatch(\n () => props.toolCall.status,\n (status) => {\n if (userToggled) return\n if (status === 'running' || status === 'awaiting-confirm') {\n isExpanded.value = true\n } else {\n isExpanded.value = !autoCollapse.value\n }\n },\n { immediate: true }\n)\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-loading' },\n running: { icon: Loading, class: 'is-loading' },\n 'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },\n success: { icon: CircleCheckFilled, class: 'is-success' },\n error: { icon: CircleClose, class: 'is-danger' },\n rejected: { icon: Close, class: 'is-danger' }\n}\n\nconst statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)\nconst statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')\n\n/** 头部图标:工具自定义图标优先,缺省用状态图标 */\nconst headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)\n\nconst parsedArguments = computed<Record<string, unknown> | null>(() => {\n if (!props.toolCall.arguments) return null\n try {\n return JSON.parse(props.toolCall.arguments)\n } catch {\n return null\n }\n})\n\n/** 折叠态参数摘要:取第一个字符串参数值 */\nconst summary = computed(() => {\n const args = parsedArguments.value\n if (!args) return ''\n for (const value of Object.values(args)) {\n if (typeof value === 'string' && value) {\n return value.length > 40 ? `${value.slice(0, 40)}…` : value\n }\n }\n return ''\n})\n\nconst prettyArguments = computed(() => {\n const args = parsedArguments.value\n if (args) return JSON.stringify(args, null, 2)\n return props.toolCall.arguments || ''\n})\n\nconst hasResult = computed(() => {\n return props.toolCall.result != null || props.toolCall.error != null\n})\n\nconst displayResult = computed(() => {\n const raw = props.toolCall.error ?? props.toolCall.result ?? ''\n try {\n return JSON.stringify(JSON.parse(raw), null, 2)\n } catch {\n return raw\n }\n})\n\n/** 使用方通过 tool-<name> 插槽自定义该工具的展示(有结果时替换整个 body) */\nconst slotBody = computed(() => {\n const slot = di?.slots[`tool-${props.toolCall.name}`]\n return slot ? () => slot({ toolCall: props.toolCall }) : undefined\n})\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/components/ai-chat/tool-call.vue"],"sourcesContent":["<template>\n <div :class=\"[cls.e('tool-call'), bem.is(toolCall.status), bem.is('expanded', isExpanded)]\">\n <UCollapseItem v-model=\"isExpanded\" @change=\"markUserToggled\">\n <template #title>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"cls.e('tool-call-name')\">{{ tool?.label ?? toolCall.name }}</span>\n <span v-if=\"summary\" :class=\"cls.e('tool-call-summary')\">{{ summary }}</span>\n\n <span\n v-if=\"toolCall.status === 'awaiting-confirm'\"\n :class=\"cls.e('tool-call-confirm')\"\n @click.stop\n >\n <UButton size=\"small\" type=\"primary\" @click=\"emit('respond', true)\">允许</UButton>\n <UButton size=\"small\" text @click=\"emit('respond', false)\">拒绝</UButton>\n </span>\n </template>\n\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool?.render\" v-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <component :is=\"slotBody\" v-else-if=\"slotBody && hasResult\" />\n <template v-else>\n <template v-if=\"prettyArguments\">\n <div :class=\"cls.e('tool-call-section')\">参数</div>\n <pre :class=\"cls.e('tool-call-code')\">{{ prettyArguments }}</pre>\n </template>\n\n <template v-if=\"hasResult\">\n <div :class=\"cls.e('tool-call-section')\">\n {{ toolCall.status === 'error' ? '错误' : '结果' }}\n </div>\n <pre :class=\"cls.e('tool-call-code')\">{{ displayResult }}</pre>\n </template>\n </template>\n </UCollapseItem>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\nimport '@veltra/desktop/components/collapse/style'\nimport { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, ref, watch, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatToolCall' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'respond', approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\nfunction markUserToggled() {\n userToggled = true\n}\n\n/** 完成后是否自动折叠:缺省有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(() => tool.value?.autoCollapse ?? !tool.value?.render)\n\nwatch(\n () => props.toolCall.status,\n (status) => {\n if (userToggled) return\n if (status === 'running' || status === 'awaiting-confirm') {\n isExpanded.value = true\n } else {\n isExpanded.value = !autoCollapse.value\n }\n },\n { immediate: true }\n)\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-loading' },\n running: { icon: Loading, class: 'is-loading' },\n 'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },\n success: { icon: CircleCheckFilled, class: 'is-success' },\n error: { icon: CircleClose, class: 'is-danger' },\n rejected: { icon: Close, class: 'is-danger' }\n}\n\nconst statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)\nconst statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')\n\n/** 头部图标:工具自定义图标优先,缺省用状态图标 */\nconst headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)\n\nconst parsedArguments = computed<Record<string, unknown> | null>(() => {\n if (!props.toolCall.arguments) return null\n try {\n return JSON.parse(props.toolCall.arguments)\n } catch {\n return null\n }\n})\n\n/** 折叠态参数摘要:取第一个字符串参数值 */\nconst summary = computed(() => {\n const args = parsedArguments.value\n if (!args) return ''\n for (const value of Object.values(args)) {\n if (typeof value === 'string' && value) {\n return value.length > 40 ? `${value.slice(0, 40)}…` : value\n }\n }\n return ''\n})\n\nconst prettyArguments = computed(() => {\n const args = parsedArguments.value\n if (args) return JSON.stringify(args, null, 2)\n return props.toolCall.arguments || ''\n})\n\nconst hasResult = computed(() => {\n return props.toolCall.result != null || props.toolCall.error != null\n})\n\nconst displayResult = computed(() => {\n const raw = props.toolCall.error ?? props.toolCall.result ?? ''\n try {\n return JSON.stringify(JSON.parse(raw), null, 2)\n } catch {\n return raw\n }\n})\n\n/** 使用方通过 tool-<name> 插槽自定义该工具的展示(有结果时替换整个 body) */\nconst slotBody = computed(() => {\n const slot = di?.slots[`tool-${props.toolCall.name}`]\n return slot ? () => slot({ toolCall: props.toolCall }) : undefined\n})\n</script>\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
+ import _sfc_main from "./components/ai-chat/ai-chat.js";
1
2
  import { ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
3
+ import { ChatModel, ChatModelOption, ChatProvider, ChatReasoningLevel } from "./providers/types.js";
2
4
  import { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed } from "./types/ai-chat.js";
3
5
  import "./types/index.js";
4
6
  import { UseChatOptions, useChat } from "./chat/use-chat.js";
5
- import { OpenAITransportOptions, createOpenAITransport } from "./chat/transports/openai.js";
7
+ import { OpenAITransport, OpenAITransportOptions, createOpenAITransport } from "./chat/transports/openai.js";
6
8
  import "./chat/index.js";
7
- import _default from "./components/ai-chat/ai-chat.vue.js";
8
- import "./components/index.js";
9
- import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool } from "./tools/ask-question/ask-question.js";
9
+ import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./tools/ask-question/ask-question.js";
10
10
  import "./tools/index.js";
11
- export { type AiChatEmits, type AiChatExposed, type AiChatProps, AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, CreateAskQuestionToolOptions, OpenAITransportOptions, ToolCallStatus, _default as UAiChat, UseChatOptions, type _AiChatExposed, createAskQuestionTool, createOpenAITransport, useChat };
11
+ export { type AiChatEmits, type AiChatExposed, type AiChatProps, type AskQuestionAnswer, type AskQuestionArgs, type AskQuestionItem, type AskQuestionResult, ChatAttachment, ChatMessage, type ChatModel, type ChatModelOption, type ChatProvider, type ChatReasoningLevel, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, OpenAITransport, OpenAITransportOptions, ToolCallStatus, _sfc_main as UAiChat, UseChatOptions, type _AiChatExposed, createOpenAITransport, useChat };
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { useChat } from "./chat/use-chat.js";
2
2
  import { createOpenAITransport } from "./chat/transports/openai.js";
3
3
  import _sfc_main from "./components/ai-chat/ai-chat.js";
4
- import { createAskQuestionTool } from "./tools/ask-question/ask-question.js";
5
- export { _sfc_main as UAiChat, createAskQuestionTool, createOpenAITransport, useChat };
4
+ export { _sfc_main as UAiChat, createOpenAITransport, useChat };
@@ -0,0 +1,47 @@
1
+ //#region src/providers/types.d.ts
2
+ /** 推理等级选项(值不透明,由宿主/服务商约定) */
3
+ interface ChatReasoningLevel {
4
+ /** 写入请求的值 */
5
+ value: string;
6
+ /** UI 文案 */
7
+ label: string;
8
+ }
9
+ /** 单个模型配置 */
10
+ interface ChatModel {
11
+ /** 模型 id(跨 Provider 全局唯一) */
12
+ id: string;
13
+ /** UI 显示名,缺省取 id */
14
+ label?: string;
15
+ /** 模型一句话描述(选择器面板副标题) */
16
+ description?: string;
17
+ /** 未设或空数组 → 不展示推理选择器 */
18
+ reasoningLevels?: ChatReasoningLevel[];
19
+ /** 默认推理等级(须落在 reasoningLevels 内) */
20
+ defaultReasoningLevel?: string;
21
+ }
22
+ /** 模型服务商配置(内嵌 models) */
23
+ interface ChatProvider {
24
+ id: string;
25
+ /** UI 显示名 */
26
+ label?: string;
27
+ /** 完整 http(s) URL 或相对路径,如 /api/ai/chat */
28
+ endpoint: string;
29
+ /** API Key(有则带 Bearer;相对路径场景可省略,走 cookie/`headers`) */
30
+ apiKey?: string;
31
+ /** Provider 级额外请求头 */
32
+ headers?: Record<string, string>;
33
+ /**
34
+ * 将选中的推理等级写入 body。
35
+ * 缺省:若 level 存在则 `body.reasoning_effort = level`(OpenAI 兼容约定)。
36
+ */
37
+ applyReasoning?: (level: string, body: Record<string, unknown>) => void;
38
+ models: ChatModel[];
39
+ }
40
+ /** UI / useChat 使用的扁平模型项(带 providerId) */
41
+ interface ChatModelOption extends ChatModel {
42
+ providerId: string;
43
+ providerLabel?: string;
44
+ }
45
+ //#endregion
46
+ export { ChatModel, ChatModelOption, ChatProvider, ChatReasoningLevel };
47
+ //# sourceMappingURL=types.d.ts.map
@@ -1,4 +1,4 @@
1
- import { ChatTool } from "../../chat/types.js";
1
+ import "../../chat/types.js";
2
2
  import { Component } from "vue";
3
3
  //#region src/tools/ask-question/ask-question.d.ts
4
4
  /** 单个提问项 */
@@ -25,21 +25,6 @@ interface AskQuestionArgs {
25
25
  interface AskQuestionResult {
26
26
  answers: AskQuestionAnswer[];
27
27
  }
28
- interface CreateAskQuestionToolOptions {
29
- /** 工具名(传给模型),默认 askQuestion */
30
- name?: string;
31
- /** 工具描述(传给模型) */
32
- description?: string;
33
- /** 工具显示名,默认「提问」 */
34
- label?: string;
35
- /** 工具图标,默认 QuestionFilled */
36
- icon?: Component;
37
- }
38
- /**
39
- * 创建内置提问工具:execute 挂起等待用户在 UI 中作答,提交后结果回灌模型。
40
- * 工具卡片由内联提问表单渲染(多题分页导航,提交后展示问答摘要,不自动折叠)。
41
- */
42
- declare function createAskQuestionTool(options?: CreateAskQuestionToolOptions): ChatTool<AskQuestionArgs>;
43
28
  //#endregion
44
- export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
29
+ export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult };
45
30
  //# sourceMappingURL=ask-question.d.ts.map
@@ -1,2 +1,2 @@
1
- import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool } from "./ask-question.js";
2
- export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
1
+ import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./ask-question.js";
2
+ export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult };
@@ -1,3 +1,4 @@
1
- import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool } from "./ask-question/ask-question.js";
1
+ import "../chat/types.js";
2
+ import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./ask-question/ask-question.js";
2
3
  import "./ask-question/index.js";
3
- export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
4
+ export type { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult };
@@ -0,0 +1,10 @@
1
+ import { createAskQuestionTool } from "./ask-question/ask-question.js";
2
+ //#region src/tools/index.ts
3
+ /** 内置工具列表(useChat 始终注入,不对外导出;新增内置工具在此注册) */
4
+ function createBuiltinTools() {
5
+ return [createAskQuestionTool()];
6
+ }
7
+ //#endregion
8
+ export { createBuiltinTools };
9
+
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/tools/index.ts"],"sourcesContent":["import type { ChatTool } from '../chat/types'\nimport { createAskQuestionTool } from './ask-question'\n\nexport type {\n AskQuestionItem,\n AskQuestionAnswer,\n AskQuestionArgs,\n AskQuestionResult\n} from './ask-question'\n\n/** 内置工具列表(useChat 始终注入,不对外导出;新增内置工具在此注册) */\nexport function createBuiltinTools(): ChatTool[] {\n return [createAskQuestionTool()]\n}\n"],"mappings":";;;AAWA,SAAgB,qBAAiC;CAC/C,OAAO,CAAC,sBAAsB,CAAC;AACjC"}
@@ -1,4 +1,5 @@
1
1
  import { ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
2
+ import { ChatModelOption } from "../providers/types.js";
2
3
  import { DeconstructValue } from "@veltra/utils";
3
4
  //#region src/types/ai-chat.d.ts
4
5
  interface AiChatProps {
@@ -10,6 +11,15 @@ interface AiChatProps {
10
11
  systemPrompt?: string;
11
12
  /** 消息列表,支持 v-model:messages 受控 */
12
13
  messages?: ChatMessage[];
14
+ /**
15
+ * 可选模型列表;有值则在输入栏展示模型选择器。
16
+ * 可从 createOpenAITransport() 返回值的 `.models` 直接传入。
17
+ */
18
+ models?: ChatModelOption[];
19
+ /** 当前模型 id,支持 v-model:model */
20
+ model?: string;
21
+ /** 当前推理等级,支持 v-model:reasoning-level */
22
+ reasoningLevel?: string;
13
23
  /** 空状态欢迎语 */
14
24
  welcome?: string;
15
25
  /** 输入框占位文本 */
@@ -24,6 +34,10 @@ interface AiChatProps {
24
34
  interface AiChatEmits {
25
35
  /** 消息列表变化 */
26
36
  (e: 'update:messages', messages: ChatMessage[]): void;
37
+ /** 当前模型变化 */
38
+ (e: 'update:model', model: string | undefined): void;
39
+ /** 当前推理等级变化 */
40
+ (e: 'update:reasoningLevel', reasoningLevel: string | undefined): void;
27
41
  /** 用户发送消息 */
28
42
  (e: 'send', message: ChatMessage): void;
29
43
  /** 一轮对话完成(无更多工具调用) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/ai",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -27,14 +27,14 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "markstream-vue": "^1.0.8"
30
+ "markstream-vue": "^1.0.9"
31
31
  },
32
32
  "peerDependencies": {
33
- "@veltra/compositions": "1.4.0",
34
- "@veltra/desktop": "1.4.0",
35
- "@veltra/icons": "1.4.0",
36
- "@veltra/styles": "1.4.0",
37
- "@veltra/utils": "1.4.0",
38
- "vue": ">=3.5.40"
33
+ "@veltra/compositions": "^1.5.0",
34
+ "@veltra/desktop": "^1.5.0",
35
+ "@veltra/icons": "^1.5.0",
36
+ "@veltra/styles": "^1.5.0",
37
+ "@veltra/utils": "^1.5.0",
38
+ "vue": ">=3.5.41"
39
39
  }
40
40
  }
@@ -243,4 +243,75 @@ describe('useChat', () => {
243
243
  await nextTick()
244
244
  expect(chat.messages.value).toHaveLength(0)
245
245
  })
246
+
247
+ it('请求携带 model 与 reasoningLevel', async () => {
248
+ const emit = createEmit()
249
+ const requests: ChatTransportRequest[] = []
250
+
251
+ const transport: ChatTransport = (req, handlers) => {
252
+ requests.push(req)
253
+ handlers.onTextDelta('ok')
254
+ }
255
+
256
+ const chat = useChat({
257
+ props: {
258
+ transport,
259
+ models: [
260
+ {
261
+ id: 'gpt-4o',
262
+ providerId: 'openai',
263
+ reasoningLevels: [
264
+ { value: 'low', label: '低' },
265
+ { value: 'high', label: '高' }
266
+ ],
267
+ defaultReasoningLevel: 'low'
268
+ }
269
+ ],
270
+ model: 'gpt-4o',
271
+ reasoningLevel: 'high'
272
+ },
273
+ emit
274
+ })
275
+
276
+ chat.send('hi')
277
+ await waitFinish(emit)
278
+
279
+ expect(requests[0]).toMatchObject({ model: 'gpt-4o', reasoningLevel: 'high' })
280
+ })
281
+
282
+ it('切换模型时校正推理等级', async () => {
283
+ const emit = createEmit()
284
+ const chat = useChat({
285
+ props: {
286
+ transport: textTransport('x'),
287
+ models: [
288
+ {
289
+ id: 'with-reason',
290
+ providerId: 'a',
291
+ reasoningLevels: [
292
+ { value: 'low', label: '低' },
293
+ { value: 'high', label: '高' }
294
+ ],
295
+ defaultReasoningLevel: 'low'
296
+ },
297
+ { id: 'no-reason', providerId: 'a' }
298
+ ],
299
+ model: 'with-reason',
300
+ reasoningLevel: 'high'
301
+ },
302
+ emit
303
+ })
304
+
305
+ await nextTick()
306
+ expect(chat.model.value).toBe('with-reason')
307
+ expect(chat.reasoningLevel.value).toBe('high')
308
+
309
+ chat.model.value = 'no-reason'
310
+ await nextTick()
311
+ expect(chat.reasoningLevel.value).toBeUndefined()
312
+
313
+ chat.model.value = 'with-reason'
314
+ await nextTick()
315
+ expect(chat.reasoningLevel.value).toBe('low')
316
+ })
246
317
  })
@@ -0,0 +1,239 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { ChatTransportHandlers } from '../../types'
4
+ import { createOpenAITransport } from '../openai'
5
+
6
+ const baseHandlers = (): ChatTransportHandlers => ({
7
+ onTextDelta: vi.fn(),
8
+ onReasoningDelta: vi.fn(),
9
+ onToolCall: vi.fn(),
10
+ onError: vi.fn()
11
+ })
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllGlobals()
15
+ vi.restoreAllMocks()
16
+ })
17
+
18
+ describe('createOpenAITransport', () => {
19
+ it('providers 为空时抛错', () => {
20
+ expect(() => createOpenAITransport({ providers: [] })).toThrow(/providers 不能为空/)
21
+ })
22
+
23
+ it('跨 Provider 模型 id 重复时抛错', () => {
24
+ expect(() =>
25
+ createOpenAITransport({
26
+ providers: [
27
+ {
28
+ id: 'a',
29
+ endpoint: 'https://a.example/v1/chat/completions',
30
+ models: [{ id: 'shared' }]
31
+ },
32
+ { id: 'b', endpoint: 'https://b.example/v1/chat/completions', models: [{ id: 'shared' }] }
33
+ ]
34
+ })
35
+ ).toThrow(/模型 id "shared" 重复/)
36
+ })
37
+
38
+ it('挂载 models 与 defaultModel', () => {
39
+ const transport = createOpenAITransport({
40
+ providers: [
41
+ {
42
+ id: 'openai',
43
+ label: 'OpenAI',
44
+ endpoint: 'https://api.openai.com/v1/chat/completions',
45
+ apiKey: 'sk-test',
46
+ models: [
47
+ { id: 'gpt-4o', label: 'GPT-4o' },
48
+ { id: 'o3-mini', label: 'o3-mini' }
49
+ ]
50
+ }
51
+ ]
52
+ })
53
+
54
+ expect(transport.defaultModel).toBe('gpt-4o')
55
+ expect(transport.models).toEqual([
56
+ { id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai', providerLabel: 'OpenAI' },
57
+ { id: 'o3-mini', label: 'o3-mini', providerId: 'openai', providerLabel: 'OpenAI' }
58
+ ])
59
+ })
60
+
61
+ it('按 model 路由到不同 endpoint,相对路径原样 fetch', async () => {
62
+ const fetchMock = vi.fn(async () => {
63
+ return new Response('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', {
64
+ status: 200,
65
+ headers: { 'Content-Type': 'text/event-stream' }
66
+ })
67
+ })
68
+ vi.stubGlobal('fetch', fetchMock)
69
+
70
+ const transport = createOpenAITransport({
71
+ providers: [
72
+ { id: 'proxy', endpoint: '/api/ai/chat', models: [{ id: 'proxy-model' }] },
73
+ {
74
+ id: 'remote',
75
+ endpoint: 'https://api.example.com/v1/chat/completions',
76
+ apiKey: 'sk-remote',
77
+ models: [{ id: 'remote-model' }]
78
+ }
79
+ ]
80
+ })
81
+
82
+ const handlers = baseHandlers()
83
+ await transport(
84
+ {
85
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
86
+ model: 'remote-model',
87
+ signal: new AbortController().signal
88
+ },
89
+ handlers
90
+ )
91
+
92
+ expect(fetchMock).toHaveBeenCalledTimes(1)
93
+ expect(fetchMock.mock.calls[0]![0]).toBe('https://api.example.com/v1/chat/completions')
94
+ const remoteInit = fetchMock.mock.calls[0]![1] as RequestInit
95
+ expect(remoteInit.headers).toMatchObject({ Authorization: 'Bearer sk-remote' })
96
+ expect(JSON.parse(String(remoteInit.body))).toMatchObject({
97
+ model: 'remote-model',
98
+ stream: true
99
+ })
100
+
101
+ await transport(
102
+ {
103
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
104
+ model: 'proxy-model',
105
+ signal: new AbortController().signal
106
+ },
107
+ handlers
108
+ )
109
+
110
+ expect(fetchMock.mock.calls[1]![0]).toBe('/api/ai/chat')
111
+ const proxyInit = fetchMock.mock.calls[1]![1] as RequestInit
112
+ expect((proxyInit.headers as Record<string, string>).Authorization).toBeUndefined()
113
+ })
114
+
115
+ it('无 apiKey 时不带 Authorization', async () => {
116
+ const fetchMock = vi.fn(async () => {
117
+ return new Response('data: [DONE]\n\n', {
118
+ status: 200,
119
+ headers: { 'Content-Type': 'text/event-stream' }
120
+ })
121
+ })
122
+ vi.stubGlobal('fetch', fetchMock)
123
+
124
+ const transport = createOpenAITransport({
125
+ providers: [{ id: 'local', endpoint: '/api/chat', models: [{ id: 'local-model' }] }]
126
+ })
127
+
128
+ await transport(
129
+ {
130
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
131
+ signal: new AbortController().signal
132
+ },
133
+ baseHandlers()
134
+ )
135
+
136
+ const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>
137
+ expect(headers.Authorization).toBeUndefined()
138
+ expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
139
+ model: 'local-model'
140
+ })
141
+ })
142
+
143
+ it('缺省将 reasoningLevel 写入 reasoning_effort', async () => {
144
+ const fetchMock = vi.fn(async () => {
145
+ return new Response('data: [DONE]\n\n', {
146
+ status: 200,
147
+ headers: { 'Content-Type': 'text/event-stream' }
148
+ })
149
+ })
150
+ vi.stubGlobal('fetch', fetchMock)
151
+
152
+ const transport = createOpenAITransport({
153
+ providers: [
154
+ {
155
+ id: 'openai',
156
+ endpoint: 'https://api.openai.com/v1/chat/completions',
157
+ apiKey: 'sk',
158
+ models: [{ id: 'o3-mini' }]
159
+ }
160
+ ]
161
+ })
162
+
163
+ await transport(
164
+ {
165
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
166
+ model: 'o3-mini',
167
+ reasoningLevel: 'high',
168
+ signal: new AbortController().signal
169
+ },
170
+ baseHandlers()
171
+ )
172
+
173
+ expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
174
+ model: 'o3-mini',
175
+ reasoning_effort: 'high'
176
+ })
177
+ })
178
+
179
+ it('applyReasoning 可自定义写入字段', async () => {
180
+ const fetchMock = vi.fn(async () => {
181
+ return new Response('data: [DONE]\n\n', {
182
+ status: 200,
183
+ headers: { 'Content-Type': 'text/event-stream' }
184
+ })
185
+ })
186
+ vi.stubGlobal('fetch', fetchMock)
187
+
188
+ const transport = createOpenAITransport({
189
+ providers: [
190
+ {
191
+ id: 'custom',
192
+ endpoint: 'https://custom.example/chat',
193
+ applyReasoning: (level, body) => {
194
+ body.thinking = { budget: level }
195
+ },
196
+ models: [{ id: 'custom-model' }]
197
+ }
198
+ ]
199
+ })
200
+
201
+ await transport(
202
+ {
203
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
204
+ reasoningLevel: '8k',
205
+ signal: new AbortController().signal
206
+ },
207
+ baseHandlers()
208
+ )
209
+
210
+ expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
211
+ thinking: { budget: '8k' }
212
+ })
213
+ expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body)).reasoning_effort).toBeUndefined()
214
+ })
215
+
216
+ it('未知 model 时通过 onError 报错且不发请求', async () => {
217
+ const fetchMock = vi.fn()
218
+ vi.stubGlobal('fetch', fetchMock)
219
+ const handlers = baseHandlers()
220
+
221
+ const transport = createOpenAITransport({
222
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'known' }] }]
223
+ })
224
+
225
+ await transport(
226
+ {
227
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
228
+ model: 'unknown',
229
+ signal: new AbortController().signal
230
+ },
231
+ handlers
232
+ )
233
+
234
+ expect(fetchMock).not.toHaveBeenCalled()
235
+ expect(handlers.onError).toHaveBeenCalledWith(
236
+ expect.objectContaining({ message: expect.stringContaining('unknown') })
237
+ )
238
+ })
239
+ })