@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.
- package/dist/chat/index.d.ts +5 -0
- package/dist/chat/transports/index.d.ts +2 -0
- package/dist/chat/transports/openai.d.ts +23 -0
- package/dist/chat/transports/openai.js +176 -0
- package/dist/chat/transports/openai.js.map +1 -0
- package/dist/chat/types.d.ts +125 -0
- package/dist/chat/use-chat.d.ts +25 -0
- package/dist/chat/use-chat.js +214 -0
- package/dist/chat/use-chat.js.map +1 -0
- package/dist/components/ai-chat/ai-chat.js +88 -0
- package/dist/components/ai-chat/ai-chat.js.map +1 -0
- package/dist/components/ai-chat/ai-chat.vue.d.ts +36 -0
- package/dist/components/ai-chat/ask-question.js +187 -0
- package/dist/components/ai-chat/ask-question.js.map +1 -0
- package/dist/components/ai-chat/chat-input.js +143 -0
- package/dist/components/ai-chat/chat-input.js.map +1 -0
- package/dist/components/ai-chat/di.js +6 -0
- package/dist/components/ai-chat/di.js.map +1 -0
- package/dist/components/ai-chat/index.d.ts +2 -0
- package/dist/components/ai-chat/message-item.js +120 -0
- package/dist/components/ai-chat/message-item.js.map +1 -0
- package/dist/components/ai-chat/message-list.js +75 -0
- package/dist/components/ai-chat/message-list.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +5 -0
- package/dist/components/ai-chat/style.js +6 -0
- package/dist/components/ai-chat/style2.css +416 -0
- package/dist/components/ai-chat/tool-call.js +151 -0
- package/dist/components/ai-chat/tool-call.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +1 -0
- package/dist/tools/ask-question/ask-question.d.ts +45 -0
- package/dist/tools/ask-question/ask-question.js +65 -0
- package/dist/tools/ask-question/ask-question.js.map +1 -0
- package/dist/tools/ask-question/deferred.js +22 -0
- package/dist/tools/ask-question/deferred.js.map +1 -0
- package/dist/tools/ask-question/index.d.ts +2 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/types/ai-chat.d.ts +49 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +40 -0
- package/src/chat/__test__/use-chat.test.ts +246 -0
- package/src/chat/index.ts +3 -0
- package/src/chat/transports/index.ts +1 -0
- package/src/chat/transports/openai.ts +237 -0
- package/src/chat/types.ts +136 -0
- package/src/chat/use-chat.ts +245 -0
- package/src/components/ai-chat/__test__/ai-chat.test.ts +316 -0
- package/src/components/ai-chat/ai-chat.vue +67 -0
- package/src/components/ai-chat/ask-question.vue +232 -0
- package/src/components/ai-chat/chat-input.vue +127 -0
- package/src/components/ai-chat/di.ts +15 -0
- package/src/components/ai-chat/index.ts +1 -0
- package/src/components/ai-chat/message-item.vue +120 -0
- package/src/components/ai-chat/message-list.vue +81 -0
- package/src/components/ai-chat/style.scss +439 -0
- package/src/components/ai-chat/style.ts +6 -0
- package/src/components/ai-chat/tool-call.vue +147 -0
- package/src/components/index.ts +1 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +4 -0
- package/src/style.ts +1 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +198 -0
- package/src/tools/ask-question/ask-question.ts +114 -0
- package/src/tools/ask-question/deferred.ts +27 -0
- package/src/tools/ask-question/index.ts +1 -0
- package/src/tools/index.ts +1 -0
- package/src/types/ai-chat.ts +56 -0
- package/src/types/index.ts +1 -0
|
@@ -0,0 +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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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
|
|
2
|
+
import { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed } from "./types/ai-chat.js";
|
|
3
|
+
import "./types/index.js";
|
|
4
|
+
import { UseChatOptions, useChat } from "./chat/use-chat.js";
|
|
5
|
+
import { OpenAITransportOptions, createOpenAITransport } from "./chat/transports/openai.js";
|
|
6
|
+
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";
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { useChat } from "./chat/use-chat.js";
|
|
2
|
+
import { createOpenAITransport } from "./chat/transports/openai.js";
|
|
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 };
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./components/ai-chat/style.js";
|
package/dist/style.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./components/ai-chat/style.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ChatTool } from "../../chat/types.js";
|
|
2
|
+
import { Component } from "vue";
|
|
3
|
+
//#region src/tools/ask-question/ask-question.d.ts
|
|
4
|
+
/** 单个提问项 */
|
|
5
|
+
interface AskQuestionItem {
|
|
6
|
+
/** 问题文案 */
|
|
7
|
+
question: string;
|
|
8
|
+
/** 预设选项(单选);缺省为纯文本题 */
|
|
9
|
+
options?: string[];
|
|
10
|
+
/** 自定义输入占位文案 */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
}
|
|
13
|
+
/** 一条问答结果 */
|
|
14
|
+
interface AskQuestionAnswer {
|
|
15
|
+
/** 问题文案 */
|
|
16
|
+
question: string;
|
|
17
|
+
/** 用户回答(选中的选项或自定义输入) */
|
|
18
|
+
answer: string;
|
|
19
|
+
}
|
|
20
|
+
/** 提问工具参数(模型输出) */
|
|
21
|
+
interface AskQuestionArgs {
|
|
22
|
+
questions: AskQuestionItem[];
|
|
23
|
+
}
|
|
24
|
+
/** 提问工具结果(序列化后回灌模型,渲染层据此展示问答摘要) */
|
|
25
|
+
interface AskQuestionResult {
|
|
26
|
+
answers: AskQuestionAnswer[];
|
|
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
|
+
//#endregion
|
|
44
|
+
export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
|
|
45
|
+
//# sourceMappingURL=ask-question.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { deferAskQuestion, takeAskQuestionDeferred } from "./deferred.js";
|
|
2
|
+
import _sfc_main from "../../components/ai-chat/ask-question.js";
|
|
3
|
+
import { QuestionFilled } from "@veltra/icons/normal";
|
|
4
|
+
//#region src/tools/ask-question/ask-question.ts
|
|
5
|
+
const DEFAULT_DESCRIPTION = "当需求不明确或存在歧义时,向用户提问以澄清。一次可提 1-4 个关键问题;每个问题可提供若干预设选项,用户可选择选项或自定义输入。用户作答后再继续生成。";
|
|
6
|
+
/** 提问参数 JSON Schema(原样传给模型) */
|
|
7
|
+
const QUESTIONS_SCHEMA = {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: { questions: {
|
|
10
|
+
type: "array",
|
|
11
|
+
description: "问题列表",
|
|
12
|
+
minItems: 1,
|
|
13
|
+
maxItems: 4,
|
|
14
|
+
items: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
question: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "问题文案"
|
|
20
|
+
},
|
|
21
|
+
options: {
|
|
22
|
+
type: "array",
|
|
23
|
+
description: "预设选项(单选);缺省为纯文本题",
|
|
24
|
+
items: { type: "string" }
|
|
25
|
+
},
|
|
26
|
+
placeholder: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "自定义输入占位文案"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
required: ["question"]
|
|
32
|
+
}
|
|
33
|
+
} },
|
|
34
|
+
required: ["questions"]
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* 创建内置提问工具:execute 挂起等待用户在 UI 中作答,提交后结果回灌模型。
|
|
38
|
+
* 工具卡片由内联提问表单渲染(多题分页导航,提交后展示问答摘要,不自动折叠)。
|
|
39
|
+
*/
|
|
40
|
+
function createAskQuestionTool(options = {}) {
|
|
41
|
+
const { name = "askQuestion", description = DEFAULT_DESCRIPTION, label = "提问", icon = QuestionFilled } = options;
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
description,
|
|
45
|
+
parameters: QUESTIONS_SCHEMA,
|
|
46
|
+
icon,
|
|
47
|
+
label,
|
|
48
|
+
render: _sfc_main,
|
|
49
|
+
execute: (_args, { toolCall, signal }) => new Promise((resolve, reject) => {
|
|
50
|
+
deferAskQuestion(toolCall.id, {
|
|
51
|
+
resolve,
|
|
52
|
+
reject
|
|
53
|
+
});
|
|
54
|
+
signal.addEventListener("abort", () => {
|
|
55
|
+
const error = /* @__PURE__ */ new Error("Aborted");
|
|
56
|
+
error.name = "AbortError";
|
|
57
|
+
takeAskQuestionDeferred(toolCall.id)?.reject(error);
|
|
58
|
+
}, { once: true });
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { createAskQuestionTool };
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=ask-question.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ask-question.js","names":["AskQuestion"],"sources":["../../../src/tools/ask-question/ask-question.ts"],"sourcesContent":["import { QuestionFilled } from '@veltra/icons/normal'\nimport type { Component } from 'vue'\n\nimport type { ChatTool } from '../../chat/types'\nimport AskQuestion from '../../components/ai-chat/ask-question.vue'\nimport { deferAskQuestion, takeAskQuestionDeferred } from './deferred'\n\n/** 单个提问项 */\nexport interface AskQuestionItem {\n /** 问题文案 */\n question: string\n /** 预设选项(单选);缺省为纯文本题 */\n options?: string[]\n /** 自定义输入占位文案 */\n placeholder?: string\n}\n\n/** 一条问答结果 */\nexport interface AskQuestionAnswer {\n /** 问题文案 */\n question: string\n /** 用户回答(选中的选项或自定义输入) */\n answer: string\n}\n\n/** 提问工具参数(模型输出) */\nexport interface AskQuestionArgs {\n questions: AskQuestionItem[]\n}\n\n/** 提问工具结果(序列化后回灌模型,渲染层据此展示问答摘要) */\nexport interface AskQuestionResult {\n answers: AskQuestionAnswer[]\n}\n\nexport interface CreateAskQuestionToolOptions {\n /** 工具名(传给模型),默认 askQuestion */\n name?: string\n /** 工具描述(传给模型) */\n description?: string\n /** 工具显示名,默认「提问」 */\n label?: string\n /** 工具图标,默认 QuestionFilled */\n icon?: Component\n}\n\nconst DEFAULT_DESCRIPTION =\n '当需求不明确或存在歧义时,向用户提问以澄清。一次可提 1-4 个关键问题;' +\n '每个问题可提供若干预设选项,用户可选择选项或自定义输入。用户作答后再继续生成。'\n\n/** 提问参数 JSON Schema(原样传给模型) */\nconst QUESTIONS_SCHEMA: Record<string, unknown> = {\n type: 'object',\n properties: {\n questions: {\n type: 'array',\n description: '问题列表',\n minItems: 1,\n maxItems: 4,\n items: {\n type: 'object',\n properties: {\n question: { type: 'string', description: '问题文案' },\n options: {\n type: 'array',\n description: '预设选项(单选);缺省为纯文本题',\n items: { type: 'string' }\n },\n placeholder: { type: 'string', description: '自定义输入占位文案' }\n },\n required: ['question']\n }\n }\n },\n required: ['questions']\n}\n\n/**\n * 创建内置提问工具:execute 挂起等待用户在 UI 中作答,提交后结果回灌模型。\n * 工具卡片由内联提问表单渲染(多题分页导航,提交后展示问答摘要,不自动折叠)。\n */\nexport function createAskQuestionTool(\n options: CreateAskQuestionToolOptions = {}\n): ChatTool<AskQuestionArgs> {\n const {\n name = 'askQuestion',\n description = DEFAULT_DESCRIPTION,\n label = '提问',\n icon = QuestionFilled\n } = options\n\n return {\n name,\n description,\n parameters: QUESTIONS_SCHEMA,\n icon,\n label,\n render: AskQuestion,\n execute: (_args, { toolCall, signal }) =>\n new Promise<AskQuestionResult>((resolve, reject) => {\n deferAskQuestion(toolCall.id, { resolve, reject })\n signal.addEventListener(\n 'abort',\n () => {\n // 不依赖 signal.reason(各环境 DOMException 行为不一),统一拒绝为 AbortError\n const error = new Error('Aborted')\n error.name = 'AbortError'\n takeAskQuestionDeferred(toolCall.id)?.reject(error)\n },\n { once: true }\n )\n })\n }\n}\n"],"mappings":";;;;AA8CA,MAAM,sBACJ;;AAIF,MAAM,mBAA4C;CAChD,MAAM;CACN,YAAY,EACV,WAAW;EACT,MAAM;EACN,aAAa;EACb,UAAU;EACV,UAAU;EACV,OAAO;GACL,MAAM;GACN,YAAY;IACV,UAAU;KAAE,MAAM;KAAU,aAAa;IAAO;IAChD,SAAS;KACP,MAAM;KACN,aAAa;KACb,OAAO,EAAE,MAAM,SAAS;IAC1B;IACA,aAAa;KAAE,MAAM;KAAU,aAAa;IAAY;GAC1D;GACA,UAAU,CAAC,UAAU;EACvB;CACF,EACF;CACA,UAAU,CAAC,WAAW;AACxB;;;;;AAMA,SAAgB,sBACd,UAAwC,CAAC,GACd;CAC3B,MAAM,EACJ,OAAO,eACP,cAAc,qBACd,QAAQ,MACR,OAAO,mBACL;CAEJ,OAAO;EACL;EACA;EACA,YAAY;EACZ;EACA;EACA,QAAQA;EACR,UAAU,OAAO,EAAE,UAAU,aAC3B,IAAI,SAA4B,SAAS,WAAW;GAClD,iBAAiB,SAAS,IAAI;IAAE;IAAS;GAAO,CAAC;GACjD,OAAO,iBACL,eACM;IAEJ,MAAM,wBAAQ,IAAI,MAAM,SAAS;IACjC,MAAM,OAAO;IACb,wBAAwB,SAAS,EAAE,CAAC,EAAE,OAAO,KAAK;GACpD,GACA,EAAE,MAAM,KAAK,CACf;EACF,CAAC;CACL;AACF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/tools/ask-question/deferred.ts
|
|
2
|
+
/** 挂起的提问 Promise,key 为 toolCallId */
|
|
3
|
+
const pending = /* @__PURE__ */ new Map();
|
|
4
|
+
/** 注册挂起的提问 Promise(由 createAskQuestionTool 的 execute 调用) */
|
|
5
|
+
function deferAskQuestion(toolCallId, deferred) {
|
|
6
|
+
pending.set(toolCallId, deferred);
|
|
7
|
+
}
|
|
8
|
+
/** 取出并移除挂起项(abort 监听用);不存在则返回 undefined */
|
|
9
|
+
function takeAskQuestionDeferred(toolCallId) {
|
|
10
|
+
const deferred = pending.get(toolCallId);
|
|
11
|
+
if (!deferred) return void 0;
|
|
12
|
+
pending.delete(toolCallId);
|
|
13
|
+
return deferred;
|
|
14
|
+
}
|
|
15
|
+
/** 兑现挂起的提问 Promise(由提问表单在用户提交时调用) */
|
|
16
|
+
function resolveAskQuestion(toolCallId, result) {
|
|
17
|
+
takeAskQuestionDeferred(toolCallId)?.resolve(result);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { deferAskQuestion, resolveAskQuestion, takeAskQuestionDeferred };
|
|
21
|
+
|
|
22
|
+
//# sourceMappingURL=deferred.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deferred.js","names":[],"sources":["../../../src/tools/ask-question/deferred.ts"],"sourcesContent":["import type { AskQuestionResult } from './ask-question'\n\ninterface AskQuestionDeferred {\n resolve: (result: AskQuestionResult) => void\n reject: (reason?: unknown) => void\n}\n\n/** 挂起的提问 Promise,key 为 toolCallId */\nconst pending = new Map<string, AskQuestionDeferred>()\n\n/** 注册挂起的提问 Promise(由 createAskQuestionTool 的 execute 调用) */\nexport function deferAskQuestion(toolCallId: string, deferred: AskQuestionDeferred): void {\n pending.set(toolCallId, deferred)\n}\n\n/** 取出并移除挂起项(abort 监听用);不存在则返回 undefined */\nexport function takeAskQuestionDeferred(toolCallId: string): AskQuestionDeferred | undefined {\n const deferred = pending.get(toolCallId)\n if (!deferred) return undefined\n pending.delete(toolCallId)\n return deferred\n}\n\n/** 兑现挂起的提问 Promise(由提问表单在用户提交时调用) */\nexport function resolveAskQuestion(toolCallId: string, result: AskQuestionResult): void {\n takeAskQuestionDeferred(toolCallId)?.resolve(result)\n}\n"],"mappings":";;AAQA,MAAM,0BAAU,IAAI,IAAiC;;AAGrD,SAAgB,iBAAiB,YAAoB,UAAqC;CACxF,QAAQ,IAAI,YAAY,QAAQ;AAClC;;AAGA,SAAgB,wBAAwB,YAAqD;CAC3F,MAAM,WAAW,QAAQ,IAAI,UAAU;CACvC,IAAI,CAAC,UAAU,OAAO,KAAA;CACtB,QAAQ,OAAO,UAAU;CACzB,OAAO;AACT;;AAGA,SAAgB,mBAAmB,YAAoB,QAAiC;CACtF,wBAAwB,UAAU,CAAC,EAAE,QAAQ,MAAM;AACrD"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool } from "./ask-question.js";
|
|
2
|
+
export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool } from "./ask-question/ask-question.js";
|
|
2
|
+
import "./ask-question/index.js";
|
|
3
|
+
export { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult, CreateAskQuestionToolOptions, createAskQuestionTool };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
|
|
2
|
+
import { DeconstructValue } from "@veltra/utils";
|
|
3
|
+
//#region src/types/ai-chat.d.ts
|
|
4
|
+
interface AiChatProps {
|
|
5
|
+
/** 传输层(必填),可使用导出的 createOpenAITransport() 创建 */
|
|
6
|
+
transport: ChatTransport;
|
|
7
|
+
/** 工具定义列表,不同场景传入不同工具赋予助手不同能力 */
|
|
8
|
+
tools?: ChatTool[];
|
|
9
|
+
/** 系统提示词 */
|
|
10
|
+
systemPrompt?: string;
|
|
11
|
+
/** 消息列表,支持 v-model:messages 受控 */
|
|
12
|
+
messages?: ChatMessage[];
|
|
13
|
+
/** 空状态欢迎语 */
|
|
14
|
+
welcome?: string;
|
|
15
|
+
/** 输入框占位文本 */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** 附件 accept 类型,默认 image/* */
|
|
18
|
+
accept?: string;
|
|
19
|
+
/** 单个附件最大字节数,默认 10MB */
|
|
20
|
+
maxAttachmentSize?: number;
|
|
21
|
+
/** 透传给内部 MarkdownRender 的属性 */
|
|
22
|
+
rendererProps?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
interface AiChatEmits {
|
|
25
|
+
/** 消息列表变化 */
|
|
26
|
+
(e: 'update:messages', messages: ChatMessage[]): void;
|
|
27
|
+
/** 用户发送消息 */
|
|
28
|
+
(e: 'send', message: ChatMessage): void;
|
|
29
|
+
/** 一轮对话完成(无更多工具调用) */
|
|
30
|
+
(e: 'finish', message: ChatMessage): void;
|
|
31
|
+
/** 对话出错 */
|
|
32
|
+
(e: 'error', error: Error): void;
|
|
33
|
+
/** 模型发起工具调用 */
|
|
34
|
+
(e: 'tool-call', toolCall: ChatToolCall): void;
|
|
35
|
+
}
|
|
36
|
+
interface _AiChatExposed {
|
|
37
|
+
/** 发送一条用户消息 */
|
|
38
|
+
send: (content: string, attachments?: ChatAttachment[]) => void;
|
|
39
|
+
/** 中断当前生成 */
|
|
40
|
+
abort: () => void;
|
|
41
|
+
/** 重新生成最后一条 assistant 回复 */
|
|
42
|
+
regenerate: () => void;
|
|
43
|
+
/** 清空消息 */
|
|
44
|
+
clear: () => void;
|
|
45
|
+
}
|
|
46
|
+
type AiChatExposed = DeconstructValue<_AiChatExposed>;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed };
|
|
49
|
+
//# sourceMappingURL=ai-chat.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@veltra/ai",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist",
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"sideEffects": [
|
|
10
|
+
"src/**/style.ts",
|
|
11
|
+
"src/**/*.scss",
|
|
12
|
+
"dist/**/style.js",
|
|
13
|
+
"dist/**/*.css"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./style": {
|
|
21
|
+
"types": "./dist/style.d.ts",
|
|
22
|
+
"import": "./dist/style.js"
|
|
23
|
+
},
|
|
24
|
+
"./*": {
|
|
25
|
+
"types": "./dist/*",
|
|
26
|
+
"import": "./dist/*"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"markstream-vue": "^1.0.8"
|
|
31
|
+
},
|
|
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"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick } from 'vue'
|
|
3
|
+
|
|
4
|
+
import type { AiChatEmits } from '../../types'
|
|
5
|
+
import type { ChatTool, ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'
|
|
6
|
+
import { useChat } from '../use-chat'
|
|
7
|
+
|
|
8
|
+
const createEmit = () => vi.fn() as unknown as AiChatEmits & ReturnType<typeof vi.fn>
|
|
9
|
+
|
|
10
|
+
/** 文本流式 mock transport */
|
|
11
|
+
const textTransport = (text: string): ChatTransport => {
|
|
12
|
+
return (_req, handlers) => {
|
|
13
|
+
handlers.onTextDelta(text)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const waitFinish = async (emit: ReturnType<typeof vi.fn>) => {
|
|
18
|
+
await vi.waitFor(() => {
|
|
19
|
+
expect(emit).toHaveBeenCalledWith('finish', expect.anything())
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('useChat', () => {
|
|
24
|
+
it('流式追加 assistant 消息并发出 finish', async () => {
|
|
25
|
+
const emit = createEmit()
|
|
26
|
+
const chat = useChat({ props: { transport: textTransport('你好') }, emit })
|
|
27
|
+
|
|
28
|
+
chat.send('hi')
|
|
29
|
+
await waitFinish(emit)
|
|
30
|
+
|
|
31
|
+
const messages = chat.messages.value
|
|
32
|
+
expect(messages).toHaveLength(2)
|
|
33
|
+
expect(messages[0]).toMatchObject({ role: 'user', content: 'hi' })
|
|
34
|
+
expect(messages[1]).toMatchObject({ role: 'assistant', content: '你好', status: 'done' })
|
|
35
|
+
expect(chat.running.value).toBe(false)
|
|
36
|
+
expect(emit).toHaveBeenCalledWith('update:messages', expect.any(Array))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('追加思考内容', async () => {
|
|
40
|
+
const emit = createEmit()
|
|
41
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
42
|
+
handlers.onReasoningDelta?.('想一下')
|
|
43
|
+
handlers.onTextDelta('答案')
|
|
44
|
+
}
|
|
45
|
+
const chat = useChat({ props: { transport }, emit })
|
|
46
|
+
|
|
47
|
+
chat.send('1+1=?')
|
|
48
|
+
await waitFinish(emit)
|
|
49
|
+
|
|
50
|
+
expect(chat.messages.value[1]).toMatchObject({ reasoning: '想一下', content: '答案' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('工具调用:执行工具、追加 tool 消息并继续第二轮请求', async () => {
|
|
54
|
+
const emit = createEmit()
|
|
55
|
+
const requests: ChatTransportRequest[] = []
|
|
56
|
+
|
|
57
|
+
const transport: ChatTransport = (req, handlers) => {
|
|
58
|
+
requests.push(req)
|
|
59
|
+
if (requests.length === 1) {
|
|
60
|
+
handlers.onToolCall?.({ id: 'call-1', name: 'add', arguments: '{"a":1,"b":2}' })
|
|
61
|
+
} else {
|
|
62
|
+
handlers.onTextDelta('结果是 3')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const execute = vi.fn(({ a, b }: { a: number; b: number }) => a + b)
|
|
67
|
+
const tools: ChatTool[] = [{ name: 'add', description: '加法', parameters: {}, execute }]
|
|
68
|
+
|
|
69
|
+
const chat = useChat({ props: { transport, tools }, emit })
|
|
70
|
+
chat.send('算一下')
|
|
71
|
+
await waitFinish(emit)
|
|
72
|
+
|
|
73
|
+
expect(execute).toHaveBeenCalledWith(
|
|
74
|
+
{ a: 1, b: 2 },
|
|
75
|
+
expect.objectContaining({ toolCall: expect.anything() })
|
|
76
|
+
)
|
|
77
|
+
expect(transport && requests).toHaveLength(2)
|
|
78
|
+
|
|
79
|
+
const messages = chat.messages.value
|
|
80
|
+
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
|
|
81
|
+
expect(messages[1].toolCalls?.[0]).toMatchObject({ status: 'success', result: '3' })
|
|
82
|
+
expect(messages[2]).toMatchObject({ role: 'tool', toolCallId: 'call-1', content: '3' })
|
|
83
|
+
|
|
84
|
+
// 第二轮请求的消息历史中包含工具结果
|
|
85
|
+
const secondReqMessages = requests[1].messages
|
|
86
|
+
expect(secondReqMessages.some((m) => m.role === 'tool' && m.toolCallId === 'call-1')).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('needsConfirm 工具:确认后才执行', async () => {
|
|
90
|
+
const emit = createEmit()
|
|
91
|
+
|
|
92
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
93
|
+
handlers.onToolCall?.({ id: 'call-1', name: 'del', arguments: '{}' })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const execute = vi.fn()
|
|
97
|
+
const tools: ChatTool[] = [
|
|
98
|
+
{ name: 'del', description: '删除', parameters: {}, needsConfirm: true, execute }
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
const chat = useChat({ props: { transport, tools }, emit })
|
|
102
|
+
chat.send('删除文件')
|
|
103
|
+
|
|
104
|
+
// 等待进入待确认状态
|
|
105
|
+
await vi.waitFor(() => {
|
|
106
|
+
expect(chat.messages.value[1].toolCalls?.[0].status).toBe('awaiting-confirm')
|
|
107
|
+
})
|
|
108
|
+
expect(execute).not.toHaveBeenCalled()
|
|
109
|
+
|
|
110
|
+
chat.respondToolCall('call-1', true)
|
|
111
|
+
await vi.waitFor(() => {
|
|
112
|
+
expect(chat.messages.value[1].toolCalls?.[0].status).toBe('success')
|
|
113
|
+
})
|
|
114
|
+
expect(execute).toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('needsConfirm 工具:拒绝后不执行并回灌拒绝信息', async () => {
|
|
118
|
+
const emit = createEmit()
|
|
119
|
+
let round = 0
|
|
120
|
+
|
|
121
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
122
|
+
round++
|
|
123
|
+
if (round === 1) {
|
|
124
|
+
handlers.onToolCall?.({ id: 'call-1', name: 'del', arguments: '{}' })
|
|
125
|
+
} else {
|
|
126
|
+
handlers.onTextDelta('好的,不删了')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const execute = vi.fn()
|
|
131
|
+
const tools: ChatTool[] = [
|
|
132
|
+
{ name: 'del', description: '删除', parameters: {}, needsConfirm: true, execute }
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
const chat = useChat({ props: { transport, tools }, emit })
|
|
136
|
+
chat.send('删除文件')
|
|
137
|
+
|
|
138
|
+
await vi.waitFor(() => {
|
|
139
|
+
expect(chat.messages.value[1].toolCalls?.[0].status).toBe('awaiting-confirm')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
chat.respondToolCall('call-1', false)
|
|
143
|
+
await waitFinish(emit)
|
|
144
|
+
|
|
145
|
+
const toolCall = chat.messages.value[1].toolCalls?.[0]
|
|
146
|
+
expect(toolCall?.status).toBe('rejected')
|
|
147
|
+
expect(execute).not.toHaveBeenCalled()
|
|
148
|
+
expect(chat.messages.value[2].content).toContain('rejected')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('abort 中断生成', async () => {
|
|
152
|
+
const emit = createEmit()
|
|
153
|
+
|
|
154
|
+
const transport: ChatTransport = (req, handlers: ChatTransportHandlers) => {
|
|
155
|
+
return new Promise<void>((resolve) => {
|
|
156
|
+
req.signal.addEventListener('abort', () => resolve(), { once: true })
|
|
157
|
+
handlers.onTextDelta('部分')
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const chat = useChat({ props: { transport }, emit })
|
|
162
|
+
chat.send('hi')
|
|
163
|
+
|
|
164
|
+
await vi.waitFor(() => {
|
|
165
|
+
expect(chat.messages.value[1].content).toBe('部分')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
chat.abort()
|
|
169
|
+
await vi.waitFor(() => {
|
|
170
|
+
expect(chat.messages.value[1].status).toBe('aborted')
|
|
171
|
+
})
|
|
172
|
+
expect(chat.running.value).toBe(false)
|
|
173
|
+
expect(emit).not.toHaveBeenCalledWith('finish', expect.anything())
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('transport 报错时标记 error 并发出 error 事件', async () => {
|
|
177
|
+
const emit = createEmit()
|
|
178
|
+
const failure = new Error('网络错误')
|
|
179
|
+
|
|
180
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
181
|
+
handlers.onError?.(failure)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const chat = useChat({ props: { transport }, emit })
|
|
185
|
+
chat.send('hi')
|
|
186
|
+
|
|
187
|
+
await vi.waitFor(() => {
|
|
188
|
+
expect(emit).toHaveBeenCalledWith('error', failure)
|
|
189
|
+
})
|
|
190
|
+
expect(chat.messages.value[1].status).toBe('error')
|
|
191
|
+
expect(chat.running.value).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('regenerate 移除最后一轮回复并重新生成', async () => {
|
|
195
|
+
const emit = createEmit()
|
|
196
|
+
let round = 0
|
|
197
|
+
|
|
198
|
+
const transport: ChatTransport = (_req, handlers) => {
|
|
199
|
+
round++
|
|
200
|
+
handlers.onTextDelta(`回答${round}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const chat = useChat({ props: { transport }, emit })
|
|
204
|
+
chat.send('问')
|
|
205
|
+
await waitFinish(emit)
|
|
206
|
+
expect(chat.messages.value[1].content).toBe('回答1')
|
|
207
|
+
|
|
208
|
+
chat.regenerate()
|
|
209
|
+
await vi.waitFor(() => {
|
|
210
|
+
expect(chat.messages.value[1].content).toBe('回答2')
|
|
211
|
+
})
|
|
212
|
+
expect(chat.messages.value).toHaveLength(2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('生成中忽略重复 send', async () => {
|
|
216
|
+
const emit = createEmit()
|
|
217
|
+
let resolveStream: (() => void) | undefined
|
|
218
|
+
|
|
219
|
+
const transport: ChatTransport = (req) => {
|
|
220
|
+
return new Promise<void>((resolve) => {
|
|
221
|
+
resolveStream = resolve
|
|
222
|
+
req.signal.addEventListener('abort', () => resolve(), { once: true })
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const chat = useChat({ props: { transport }, emit })
|
|
227
|
+
chat.send('第一条')
|
|
228
|
+
chat.send('第二条')
|
|
229
|
+
resolveStream?.()
|
|
230
|
+
|
|
231
|
+
await waitFinish(emit)
|
|
232
|
+
expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('clear 清空消息', async () => {
|
|
236
|
+
const emit = createEmit()
|
|
237
|
+
const chat = useChat({ props: { transport: textTransport('x') }, emit })
|
|
238
|
+
|
|
239
|
+
chat.send('hi')
|
|
240
|
+
await waitFinish(emit)
|
|
241
|
+
|
|
242
|
+
chat.clear()
|
|
243
|
+
await nextTick()
|
|
244
|
+
expect(chat.messages.value).toHaveLength(0)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './openai'
|