@veltra/ai 2.1.6 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +42 -0
  2. package/dist/chat/fold.d.ts +41 -0
  3. package/dist/chat/fold.js +208 -0
  4. package/dist/chat/fold.js.map +1 -0
  5. package/dist/chat/index.d.ts +4 -2
  6. package/dist/chat/session.d.ts +123 -0
  7. package/dist/chat/session.js +79 -0
  8. package/dist/chat/session.js.map +1 -0
  9. package/dist/chat/types.d.ts +38 -1
  10. package/dist/chat/use-chat-helpers.js +39 -0
  11. package/dist/chat/use-chat-helpers.js.map +1 -0
  12. package/dist/chat/use-chat-session.js +68 -0
  13. package/dist/chat/use-chat-session.js.map +1 -0
  14. package/dist/chat/use-chat.d.ts +50 -2
  15. package/dist/chat/use-chat.js +73 -39
  16. package/dist/chat/use-chat.js.map +1 -1
  17. package/dist/components/ai-chat/ai-chat.js +49 -14
  18. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  19. package/dist/components/ai-chat/approval-banner.js +54 -0
  20. package/dist/components/ai-chat/approval-banner.js.map +1 -0
  21. package/dist/components/ai-chat/ask-question.js +32 -51
  22. package/dist/components/ai-chat/ask-question.js.map +1 -1
  23. package/dist/components/ai-chat/di.js.map +1 -1
  24. package/dist/components/ai-chat/job-bar.js +48 -0
  25. package/dist/components/ai-chat/job-bar.js.map +1 -0
  26. package/dist/components/ai-chat/message-list.js +5 -2
  27. package/dist/components/ai-chat/message-list.js.map +1 -1
  28. package/dist/components/ai-chat/queue-list.js +9 -3
  29. package/dist/components/ai-chat/queue-list.js.map +1 -1
  30. package/dist/components/ai-chat/side-panel.js +12 -30
  31. package/dist/components/ai-chat/side-panel.js.map +1 -1
  32. package/dist/components/ai-chat/style2.css +146 -8
  33. package/dist/components/ai-chat/tool-call.js +51 -40
  34. package/dist/components/ai-chat/tool-call.js.map +1 -1
  35. package/dist/components/ai-chat/tool-icons.js +31 -0
  36. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  37. package/dist/components/ai-chat/tool-json.js +40 -0
  38. package/dist/components/ai-chat/tool-json.js.map +1 -0
  39. package/dist/components/ai-orb/style2.css +1 -1
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +3 -1
  42. package/dist/tools/ask-question/ask-question.js +48 -2
  43. package/dist/tools/ask-question/ask-question.js.map +1 -1
  44. package/dist/types/ai-chat.d.ts +17 -6
  45. package/package.json +7 -7
  46. package/src/chat/__test__/fold.test.ts +136 -0
  47. package/src/chat/__test__/session.test.ts +191 -0
  48. package/src/chat/__test__/use-chat.test.ts +268 -1
  49. package/src/chat/fold.ts +260 -0
  50. package/src/chat/index.ts +2 -0
  51. package/src/chat/session.ts +168 -0
  52. package/src/chat/types.ts +40 -0
  53. package/src/chat/use-chat-helpers.ts +39 -0
  54. package/src/chat/use-chat-session.ts +87 -0
  55. package/src/chat/use-chat.ts +90 -44
  56. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  57. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  58. package/src/components/ai-chat/ai-chat.vue +39 -4
  59. package/src/components/ai-chat/approval-banner.vue +37 -0
  60. package/src/components/ai-chat/ask-question.vue +38 -54
  61. package/src/components/ai-chat/di.ts +6 -4
  62. package/src/components/ai-chat/job-bar.vue +41 -0
  63. package/src/components/ai-chat/message-list.vue +4 -1
  64. package/src/components/ai-chat/queue-list.vue +3 -1
  65. package/src/components/ai-chat/side-panel.vue +15 -13
  66. package/src/components/ai-chat/style.scss +158 -6
  67. package/src/components/ai-chat/tool-call.vue +46 -34
  68. package/src/components/ai-chat/tool-icons.ts +33 -0
  69. package/src/components/ai-chat/tool-json.vue +52 -0
  70. package/src/tools/ask-question/ask-question.ts +53 -4
  71. package/src/types/ai-chat.ts +17 -5
@@ -14,7 +14,7 @@
14
14
  * --u-height-default: 32px;
15
15
  * --u-height-small: 24px;
16
16
  */
17
- /** 暗色:data-theme 与系统偏好,与 UITheme.injectBuiltInThemes 选择器策略一致 */
17
+ /** 暗色:`UITheme.render()` 按主题系列写入 html[data-theme] */
18
18
  .u-ai-orb {
19
19
  display: block;
20
20
  flex-shrink: 0;
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import _sfc_main$1 from "./components/ai-orb/ai-orb.js";
2
2
  import _sfc_main from "./components/ai-chat/ai-chat.js";
3
- import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
3
+ import { ChatAttachment, ChatJob, ChatJobStatus, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolContext, ChatToolMeta, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
4
+ import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./tools/ask-question/ask-question.js";
5
+ import "./tools/index.js";
6
+ import { ChatSessionAdapter, ChatSessionEvent, ChatSessionTransport, createServerTransport, isServerTransport } from "./chat/session.js";
4
7
  import { ChatModel, ChatModelOption, ChatProvider, ChatReasoningLevel } from "./providers/types.js";
5
8
  import { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed } from "./types/ai-chat.js";
6
9
  import { AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction, AiOrbStatus } from "./types/ai-orb.js";
7
10
  import "./types/index.js";
11
+ import { ChatFoldState, ChatPendingApproval, createFoldState, foldSessionEvent } from "./chat/fold.js";
8
12
  import { UseChatOptions, useChat } from "./chat/use-chat.js";
9
13
  import { OpenAITransport, OpenAITransportOptions, createOpenAITransport } from "./chat/transports/openai.js";
10
14
  import "./chat/index.js";
11
- import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./tools/ask-question/ask-question.js";
12
- import "./tools/index.js";
13
- export { type AiChatEmits, type AiChatExposed, type AiChatProps, type AiOrbEmits, type AiOrbExposed, type AiOrbProps, type AiOrbReaction, type AiOrbStatus, type AskQuestionAnswer, type AskQuestionArgs, type AskQuestionItem, type AskQuestionResult, ChatAttachment, ChatMessage, type ChatModel, type ChatModelOption, type ChatProvider, ChatQueuedMessage, type ChatReasoningLevel, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, OpenAITransport, OpenAITransportOptions, ToolCallStatus, _sfc_main as UAiChat, _sfc_main$1 as UAiOrb, UseChatOptions, type _AiChatExposed, createOpenAITransport, useChat };
15
+ export { type AiChatEmits, type AiChatExposed, type AiChatProps, type AiOrbEmits, type AiOrbExposed, type AiOrbProps, type AiOrbReaction, type AiOrbStatus, type AskQuestionAnswer, type AskQuestionArgs, type AskQuestionItem, type AskQuestionResult, ChatAttachment, ChatFoldState, ChatJob, ChatJobStatus, ChatMessage, type ChatModel, type ChatModelOption, ChatPendingApproval, type ChatProvider, ChatQueuedMessage, type ChatReasoningLevel, ChatSessionAdapter, ChatSessionEvent, ChatSessionTransport, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolContext, ChatToolMeta, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, OpenAITransport, OpenAITransportOptions, ToolCallStatus, _sfc_main as UAiChat, _sfc_main$1 as UAiOrb, UseChatOptions, type _AiChatExposed, createFoldState, createOpenAITransport, createServerTransport, foldSessionEvent, isServerTransport, useChat };
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { createServerTransport, isServerTransport } from "./chat/session.js";
2
+ import { createFoldState, foldSessionEvent } from "./chat/fold.js";
1
3
  import { useChat } from "./chat/use-chat.js";
2
4
  import { createOpenAITransport } from "./chat/transports/openai.js";
3
5
  import _sfc_main$1 from "./components/ai-orb/ai-orb.js";
4
6
  import _sfc_main from "./components/ai-chat/ai-chat.js";
5
- export { _sfc_main as UAiChat, _sfc_main$1 as UAiOrb, createOpenAITransport, useChat };
7
+ export { _sfc_main as UAiChat, _sfc_main$1 as UAiOrb, createFoldState, createOpenAITransport, createServerTransport, foldSessionEvent, isServerTransport, useChat };
@@ -1,5 +1,6 @@
1
- import { deferAskQuestion, takeAskQuestionDeferred } from "./deferred.js";
2
1
  import _sfc_main from "../../components/ai-chat/ask-question.js";
2
+ import { deferAskQuestion, resolveAskQuestion, takeAskQuestionDeferred } from "./deferred.js";
3
+ import { computed, defineComponent, h } from "vue";
3
4
  import { QuestionFilled } from "@veltra/icons/normal";
4
5
  //#region src/tools/ask-question/ask-question.ts
5
6
  const DEFAULT_DESCRIPTION = "当需求不明确或存在歧义时,向用户提问以澄清。一次可提 1-4 个关键问题;每个问题可提供若干预设选项,用户可选择选项或自定义输入。用户作答后再继续生成。";
@@ -33,6 +34,51 @@ const QUESTIONS_SCHEMA = {
33
34
  } },
34
35
  required: ["questions"]
35
36
  };
37
+ function parseQuestions(toolCall) {
38
+ try {
39
+ const args = JSON.parse(toolCall.arguments || "{}");
40
+ return Array.isArray(args.questions) ? args.questions : [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+ function parseAnswers(toolCall) {
46
+ if (toolCall.status !== "success" || !toolCall.result) return void 0;
47
+ try {
48
+ const result = JSON.parse(toolCall.result);
49
+ return Array.isArray(result.answers) ? result.answers : void 0;
50
+ } catch {
51
+ return;
52
+ }
53
+ }
54
+ function statusError(toolCall) {
55
+ if (toolCall.status !== "error") return void 0;
56
+ const err = toolCall.error ?? "";
57
+ if (err.includes("Abort") || err.includes("取消")) return "已取消";
58
+ return err || "提问失败";
59
+ }
60
+ /** 把 toolCall 转成提问表单的 questions / onSubmit;提交仍走 resolveAskQuestion */
61
+ const AskQuestionRender = defineComponent({
62
+ name: "AskQuestionRender",
63
+ props: { toolCall: {
64
+ type: Object,
65
+ required: true
66
+ } },
67
+ setup(props) {
68
+ const questions = computed(() => parseQuestions(props.toolCall));
69
+ const answers = computed(() => parseAnswers(props.toolCall));
70
+ const error = computed(() => statusError(props.toolCall));
71
+ const onSubmit = (value) => {
72
+ resolveAskQuestion(props.toolCall.id, { answers: Array.isArray(value) ? value : value.answers });
73
+ };
74
+ return () => h(_sfc_main, {
75
+ questions: questions.value,
76
+ answers: answers.value,
77
+ error: error.value,
78
+ onSubmit
79
+ });
80
+ }
81
+ });
36
82
  /**
37
83
  * 创建内置提问工具:execute 挂起等待用户在 UI 中作答,提交后结果回灌模型。
38
84
  * 工具卡片由内联提问表单渲染(多题分页导航,提交后展示问答摘要,不自动折叠)。
@@ -45,7 +91,7 @@ function createAskQuestionTool(options = {}) {
45
91
  parameters: QUESTIONS_SCHEMA,
46
92
  icon,
47
93
  label,
48
- render: _sfc_main,
94
+ render: AskQuestionRender,
49
95
  execute: (_args, { toolCall, signal }) => new Promise((resolve, reject) => {
50
96
  deferAskQuestion(toolCall.id, {
51
97
  resolve,
@@ -1 +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"}
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 { computed, defineComponent, h, type Component, type PropType } from 'vue'\n\nimport type { ChatTool, ChatToolCall } from '../../chat/types'\nimport AskQuestion from '../../components/ai-chat/ask-question.vue'\nimport { deferAskQuestion, resolveAskQuestion, 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\nfunction parseQuestions(toolCall: ChatToolCall): AskQuestionItem[] {\n try {\n const args = JSON.parse(toolCall.arguments || '{}') as Partial<AskQuestionArgs>\n return Array.isArray(args.questions) ? args.questions : []\n } catch {\n return []\n }\n}\n\nfunction parseAnswers(toolCall: ChatToolCall): AskQuestionAnswer[] | undefined {\n if (toolCall.status !== 'success' || !toolCall.result) return undefined\n try {\n const result = JSON.parse(toolCall.result) as Partial<AskQuestionResult>\n return Array.isArray(result.answers) ? result.answers : undefined\n } catch {\n return undefined\n }\n}\n\nfunction statusError(toolCall: ChatToolCall): string | undefined {\n if (toolCall.status !== 'error') return undefined\n const err = toolCall.error ?? ''\n if (err.includes('Abort') || err.includes('取消')) return '已取消'\n return err || '提问失败'\n}\n\n/** 把 toolCall 转成提问表单的 questions / onSubmit;提交仍走 resolveAskQuestion */\nconst AskQuestionRender = defineComponent({\n name: 'AskQuestionRender',\n props: { toolCall: { type: Object as PropType<ChatToolCall>, required: true } },\n setup(props) {\n const questions = computed(() => parseQuestions(props.toolCall))\n const answers = computed(() => parseAnswers(props.toolCall))\n const error = computed(() => statusError(props.toolCall))\n const onSubmit = (value: AskQuestionAnswer[] | AskQuestionResult) => {\n resolveAskQuestion(props.toolCall.id, {\n answers: Array.isArray(value) ? value : value.answers\n })\n }\n return () =>\n h(AskQuestion, {\n questions: questions.value,\n answers: answers.value,\n error: error.value,\n onSubmit\n })\n }\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: AskQuestionRender,\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;AAEA,SAAS,eAAe,UAA2C;CACjE,IAAI;EACF,MAAM,OAAO,KAAK,MAAM,SAAS,aAAa,IAAI;EAClD,OAAO,MAAM,QAAQ,KAAK,SAAS,IAAI,KAAK,YAAY,CAAC;CAC3D,QAAQ;EACN,OAAO,CAAC;CACV;AACF;AAEA,SAAS,aAAa,UAAyD;CAC7E,IAAI,SAAS,WAAW,aAAa,CAAC,SAAS,QAAQ,OAAO,KAAA;CAC9D,IAAI;EACF,MAAM,SAAS,KAAK,MAAM,SAAS,MAAM;EACzC,OAAO,MAAM,QAAQ,OAAO,OAAO,IAAI,OAAO,UAAU,KAAA;CAC1D,QAAQ;EACN;CACF;AACF;AAEA,SAAS,YAAY,UAA4C;CAC/D,IAAI,SAAS,WAAW,SAAS,OAAO,KAAA;CACxC,MAAM,MAAM,SAAS,SAAS;CAC9B,IAAI,IAAI,SAAS,OAAO,KAAK,IAAI,SAAS,IAAI,GAAG,OAAO;CACxD,OAAO,OAAO;AAChB;;AAGA,MAAM,oBAAoB,gBAAgB;CACxC,MAAM;CACN,OAAO,EAAE,UAAU;EAAE,MAAM;EAAkC,UAAU;CAAK,EAAE;CAC9E,MAAM,OAAO;EACX,MAAM,YAAY,eAAe,eAAe,MAAM,QAAQ,CAAC;EAC/D,MAAM,UAAU,eAAe,aAAa,MAAM,QAAQ,CAAC;EAC3D,MAAM,QAAQ,eAAe,YAAY,MAAM,QAAQ,CAAC;EACxD,MAAM,YAAY,UAAmD;GACnE,mBAAmB,MAAM,SAAS,IAAI,EACpC,SAAS,MAAM,QAAQ,KAAK,IAAI,QAAQ,MAAM,QAChD,CAAC;EACH;EACA,aACE,EAAEA,WAAa;GACb,WAAW,UAAU;GACrB,SAAS,QAAQ;GACjB,OAAO,MAAM;GACb;EACF,CAAC;CACL;AACF,CAAC;;;;;AAMD,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,QAAQ;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"}
@@ -1,13 +1,17 @@
1
- import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
1
+ import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolMeta, ChatTransport } from "../chat/types.js";
2
+ import { ChatSessionTransport } from "../chat/session.js";
2
3
  import { ChatModelOption } from "../providers/types.js";
3
- import { Ref } from "vue";
4
+ import { Component, Ref } from "vue";
4
5
  import { DeconstructValue } from "@veltra/utils";
5
6
  //#region src/types/ai-chat.d.ts
6
7
  interface AiChatProps {
7
- /** 传输层(必填),可使用导出的 createOpenAITransport() 创建 */
8
- transport: ChatTransport;
9
- /** 工具定义列表,不同场景传入不同工具赋予助手不同能力 */
10
- tools?: ChatTool[];
8
+ /** 传输层(必填);函数型 ChatTransport 或 session 对象,可使用 createOpenAITransport / createServerTransport */
9
+ transport: ChatTransport | ChatSessionTransport;
10
+ /**
11
+ * 工具列表。函数 transport 传入 ChatTool[](必有 execute);
12
+ * session 下可为 ChatToolMeta[](仅渲染元信息)。ChatTool[] 仍可赋值。
13
+ */
14
+ tools?: (ChatTool | ChatToolMeta)[];
11
15
  /** 系统提示词 */
12
16
  systemPrompt?: string;
13
17
  /**
@@ -41,6 +45,13 @@ interface AiChatProps {
41
45
  * 默认 false:仅在拿到 usage 时显示会话累计「总 token」。接口未返回 usage 时不展示。
42
46
  */
43
47
  tokenUsageDetail?: boolean;
48
+ /**
49
+ * 覆盖包内 toolName → icon 映射(精确名优先于内置名称规则)。
50
+ * 缺省不传则走内置表;未命中的名称仍用兜底图标,不得 throw。
51
+ */
52
+ toolIcons?: Record<string, Component>;
53
+ /** 只读:不展示输入区、欢迎语不可发送、队列无插队/编辑/移除。默认 false */
54
+ readonly?: boolean;
44
55
  }
45
56
  interface AiChatEmits {
46
57
  /** 消息列表变化 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/ai",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -36,14 +36,14 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "markstream-vue": "^2.0.0"
39
+ "markstream-vue": "^2.0.8"
40
40
  },
41
41
  "peerDependencies": {
42
- "@veltra/compositions": "^1.6.4",
43
- "@veltra/desktop": "^1.6.4",
42
+ "@veltra/compositions": "^1.7.9",
43
+ "@veltra/desktop": "^1.7.9",
44
44
  "@veltra/icons": "^1.5.0",
45
- "@veltra/styles": "^1.6.4",
46
- "@veltra/utils": "^1.6.4",
47
- "vue": ">=3.5.41"
45
+ "@veltra/styles": "^1.7.9",
46
+ "@veltra/utils": "^1.7.9",
47
+ "vue": ">=3.5.42"
48
48
  }
49
49
  }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { createFoldState, foldSessionEvent } from '../fold'
4
+ import type { ChatSessionEvent } from '../session'
5
+
6
+ const foldAll = (events: ChatSessionEvent[]) => events.reduce(foldSessionEvent, createFoldState())
7
+
8
+ describe('foldSessionEvent', () => {
9
+ it('user/message 按服务端回显追加,不制造本地占位', () => {
10
+ const state = foldAll([{ type: 'user/message', messageId: 'u1', seq: 1, content: '你好' }])
11
+ expect(state.messages).toEqual([{ id: 'u1', role: 'user', content: '你好' }])
12
+ })
13
+
14
+ it('assistant/chunk 按 messageId 累积 content 与 reasoning', () => {
15
+ const state = foldAll([
16
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '你', reasoningDelta: '想' },
17
+ { type: 'assistant/chunk', messageId: 'a1', seq: 2, delta: '好', reasoningDelta: '一下' }
18
+ ])
19
+ expect(state.messages).toHaveLength(1)
20
+ expect(state.messages[0]).toMatchObject({
21
+ id: 'a1',
22
+ role: 'assistant',
23
+ content: '你好',
24
+ reasoning: '想一下',
25
+ status: 'streaming'
26
+ })
27
+ })
28
+
29
+ it('assistant/message 定稿内容与状态', () => {
30
+ const state = foldAll([
31
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '草' },
32
+ {
33
+ type: 'assistant/message',
34
+ messageId: 'a1',
35
+ seq: 2,
36
+ content: '你好世界',
37
+ reasoning: '想完了'
38
+ }
39
+ ])
40
+ expect(state.messages[0]).toMatchObject({
41
+ id: 'a1',
42
+ content: '你好世界',
43
+ reasoning: '想完了',
44
+ status: 'done'
45
+ })
46
+ })
47
+
48
+ it('tool/call 追加 pending 的 ChatToolCall 并保留 view', () => {
49
+ const view = { preview: 'ls' }
50
+ const state = foldAll([
51
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '' },
52
+ { type: 'tool/call', callId: 'c1', name: 'bash', arguments: '{"cmd":"ls"}', seq: 2, view }
53
+ ])
54
+ expect(state.messages[0]?.toolCalls).toEqual([
55
+ { id: 'c1', name: 'bash', arguments: '{"cmd":"ls"}', status: 'pending', view }
56
+ ])
57
+ })
58
+
59
+ it('tool/result 按 callId 更新 status / result / error / view', () => {
60
+ const state = foldAll([
61
+ { type: 'tool/call', callId: 'c1', name: 'bash', arguments: '{}', seq: 1, view: { step: 1 } },
62
+ {
63
+ type: 'tool/result',
64
+ callId: 'c1',
65
+ status: 'error',
66
+ result: 'out',
67
+ error: 'boom',
68
+ seq: 2,
69
+ view: { step: 2 }
70
+ }
71
+ ])
72
+ expect(state.messages[0]?.toolCalls?.[0]).toMatchObject({
73
+ id: 'c1',
74
+ status: 'error',
75
+ result: 'out',
76
+ error: 'boom',
77
+ view: { step: 2 }
78
+ })
79
+ })
80
+
81
+ it('queue/snapshot 与 jobs/snapshot 整体替换', () => {
82
+ const state = foldAll([
83
+ { type: 'queue/snapshot', items: [{ id: 'q1', content: '旧' }] },
84
+ {
85
+ type: 'jobs/snapshot',
86
+ jobs: [{ id: 'j1', kind: 'bash', label: '旧作业', status: 'running' }]
87
+ },
88
+ { type: 'queue/snapshot', items: [{ id: 'q2', content: '新' }] },
89
+ { type: 'jobs/snapshot', jobs: [{ id: 'j2', kind: 'web', label: '新作业', status: 'done' }] }
90
+ ])
91
+ expect(state.queue).toEqual([{ id: 'q2', content: '新' }])
92
+ expect(state.jobs).toEqual([{ id: 'j2', kind: 'web', label: '新作业', status: 'done' }])
93
+ })
94
+
95
+ it('projection:tokenUsage 合并、title 取出、其余进 projections', () => {
96
+ const state = foldAll([
97
+ {
98
+ type: 'projection',
99
+ key: 'tokenUsage',
100
+ seq: 1,
101
+ value: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }
102
+ },
103
+ {
104
+ type: 'projection',
105
+ key: 'tokenUsage',
106
+ seq: 2,
107
+ value: { promptTokens: 4, completionTokens: 5, totalTokens: 9 }
108
+ },
109
+ { type: 'projection', key: 'title', seq: 3, value: '会话标题' },
110
+ { type: 'projection', key: 'extra', seq: 4, value: { n: 1 } }
111
+ ])
112
+ expect(state.tokenUsage).toEqual({ promptTokens: 5, completionTokens: 7, totalTokens: 12 })
113
+ expect(state.title).toBe('会话标题')
114
+ expect(state.projections).toEqual({ extra: { n: 1 } })
115
+ expect(state.projections).not.toHaveProperty('title')
116
+ expect(state.projections).not.toHaveProperty('tokenUsage')
117
+ })
118
+
119
+ it('乱序 seq 不覆盖已应用事件', () => {
120
+ const applied = foldSessionEvent(createFoldState(), {
121
+ type: 'user/message',
122
+ messageId: 'u2',
123
+ seq: 2,
124
+ content: '后到的先到'
125
+ })
126
+ const skipped = foldSessionEvent(applied, {
127
+ type: 'user/message',
128
+ messageId: 'u1',
129
+ seq: 1,
130
+ content: '乱序应丢弃'
131
+ })
132
+ expect(skipped).toBe(applied)
133
+ expect(skipped.messages).toEqual([{ id: 'u2', role: 'user', content: '后到的先到' }])
134
+ expect(skipped.lastSeq).toBe(2)
135
+ })
136
+ })
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createFoldState, foldSessionEvent } from '../fold'
4
+ import * as chat from '../index'
5
+ import {
6
+ createServerTransport,
7
+ isServerTransport,
8
+ type ChatSessionAdapter,
9
+ type ChatSessionEvent
10
+ } from '../session'
11
+
12
+ function createFakeAdapter(options?: {
13
+ catchUp?: ChatSessionEvent[]
14
+ }): ChatSessionAdapter & {
15
+ emit: (event: ChatSessionEvent) => void
16
+ disconnect: () => void
17
+ unsubscribed: () => boolean
18
+ } {
19
+ let listener: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void } | undefined
20
+ let unsubscribed = false
21
+
22
+ const adapter: ChatSessionAdapter = {
23
+ subscribe(handlers) {
24
+ listener = handlers
25
+ return () => {
26
+ unsubscribed = true
27
+ listener = undefined
28
+ }
29
+ },
30
+ send: vi.fn(async () => {}),
31
+ cancel: vi.fn(async () => {}),
32
+ respond: vi.fn(async () => {}),
33
+ fetchHistory: vi.fn(async () => ({ events: options?.catchUp ?? [], hasMore: false })),
34
+ selectModel: vi.fn(async () => {})
35
+ }
36
+
37
+ return Object.assign(adapter, {
38
+ emit(event: ChatSessionEvent) {
39
+ listener?.onEvent(event)
40
+ },
41
+ disconnect() {
42
+ listener?.onDisconnect?.()
43
+ },
44
+ unsubscribed: () => unsubscribed
45
+ })
46
+ }
47
+
48
+ describe('isServerTransport', () => {
49
+ it('仅对 kind: session 的对象为 true', () => {
50
+ const transport = createServerTransport(createFakeAdapter())
51
+ expect(transport.kind).toBe('session')
52
+ expect(isServerTransport(transport)).toBe(true)
53
+ expect(isServerTransport({ kind: 'session' })).toBe(true)
54
+ expect(isServerTransport(() => {})).toBe(false)
55
+ expect(isServerTransport(null)).toBe(false)
56
+ expect(isServerTransport(undefined)).toBe(false)
57
+ expect(isServerTransport({})).toBe(false)
58
+ expect(isServerTransport({ kind: 'client' })).toBe(false)
59
+ })
60
+ })
61
+
62
+ describe('createServerTransport', () => {
63
+ it('主入口导出 A1 符号且不导出 createBedrockTransport', () => {
64
+ expect(chat.createServerTransport).toBeTypeOf('function')
65
+ expect(chat.isServerTransport).toBeTypeOf('function')
66
+ expect(chat.foldSessionEvent).toBeTypeOf('function')
67
+ expect(chat.createFoldState).toBeTypeOf('function')
68
+ expect('createBedrockTransport' in chat).toBe(false)
69
+ })
70
+
71
+ it('后到的更小 seq 丢弃且 console.warn,不打乱已折叠状态', () => {
72
+ const adapter = createFakeAdapter()
73
+ const transport = createServerTransport(adapter)
74
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
75
+ let state = createFoldState()
76
+ transport.open({
77
+ onEvent(event) {
78
+ state = foldSessionEvent(state, event)
79
+ }
80
+ })
81
+
82
+ adapter.emit({ type: 'user/message', messageId: 'u2', seq: 2, content: '先到' })
83
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: '乱序' })
84
+
85
+ expect(warn).toHaveBeenCalledTimes(1)
86
+ expect(warn.mock.calls[0]?.[0]).toMatch(/丢弃乱序事件/)
87
+ expect(state.messages).toHaveLength(1)
88
+ expect(state.messages[0]).toMatchObject({ id: 'u2', content: '先到' })
89
+ warn.mockRestore()
90
+ })
91
+
92
+ it('open 的 disposer 释放订阅', () => {
93
+ const adapter = createFakeAdapter()
94
+ const transport = createServerTransport(adapter)
95
+ const onEvent = vi.fn()
96
+ const dispose = transport.open({ onEvent })
97
+
98
+ adapter.emit({ type: 'finish' })
99
+ expect(onEvent).toHaveBeenCalledTimes(1)
100
+
101
+ dispose()
102
+ expect(adapter.unsubscribed()).toBe(true)
103
+ adapter.emit({ type: 'finish' })
104
+ adapter.disconnect()
105
+ expect(onEvent).toHaveBeenCalledTimes(1)
106
+ expect(adapter.fetchHistory).not.toHaveBeenCalled()
107
+ })
108
+
109
+ it('断开后 fetchHistory(lastSeq) 补拉再 fold', async () => {
110
+ const catchUp: ChatSessionEvent[] = [
111
+ { type: 'user/message', messageId: 'u2', seq: 2, content: '补拉' }
112
+ ]
113
+ const adapter = createFakeAdapter({ catchUp })
114
+ const transport = createServerTransport(adapter)
115
+ let state = createFoldState()
116
+ const onDisconnect = vi.fn()
117
+ transport.open({
118
+ onEvent(event) {
119
+ state = foldSessionEvent(state, event)
120
+ },
121
+ onDisconnect
122
+ })
123
+
124
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: '已有' })
125
+ adapter.disconnect()
126
+
127
+ await vi.waitFor(() => {
128
+ expect(adapter.fetchHistory).toHaveBeenCalledWith(1)
129
+ expect(state.messages.map((m) => m.id)).toEqual(['u1', 'u2'])
130
+ expect(onDisconnect).toHaveBeenCalledTimes(1)
131
+ })
132
+ })
133
+
134
+ it('send / cancel / respond / selectModel 同一动作未完成时忽略重复调用', async () => {
135
+ const adapter = createFakeAdapter()
136
+ let releaseSend!: () => void
137
+ let releaseCancel!: () => void
138
+ let releaseRespond!: () => void
139
+ let releaseSelect!: () => void
140
+ adapter.send = vi.fn(
141
+ () =>
142
+ new Promise<void>((resolve) => {
143
+ releaseSend = resolve
144
+ })
145
+ )
146
+ adapter.cancel = vi.fn(
147
+ () =>
148
+ new Promise<void>((resolve) => {
149
+ releaseCancel = resolve
150
+ })
151
+ )
152
+ adapter.respond = vi.fn(
153
+ () =>
154
+ new Promise<void>((resolve) => {
155
+ releaseRespond = resolve
156
+ })
157
+ )
158
+ adapter.selectModel = vi.fn(
159
+ () =>
160
+ new Promise<void>((resolve) => {
161
+ releaseSelect = resolve
162
+ })
163
+ )
164
+
165
+ const transport = createServerTransport(adapter)
166
+
167
+ const send1 = transport.send('a')
168
+ const send2 = transport.send('b')
169
+ expect(adapter.send).toHaveBeenCalledTimes(1)
170
+ releaseSend()
171
+ await Promise.all([send1, send2])
172
+
173
+ const cancel1 = transport.cancel()
174
+ const cancel2 = transport.cancel()
175
+ expect(adapter.cancel).toHaveBeenCalledTimes(1)
176
+ releaseCancel()
177
+ await Promise.all([cancel1, cancel2])
178
+
179
+ const respond1 = transport.respond('rpc-1', true)
180
+ const respond2 = transport.respond('rpc-2', false)
181
+ expect(adapter.respond).toHaveBeenCalledTimes(1)
182
+ releaseRespond()
183
+ await Promise.all([respond1, respond2])
184
+
185
+ const select1 = transport.selectModel('p', 'm1')
186
+ const select2 = transport.selectModel('p', 'm2')
187
+ expect(adapter.selectModel).toHaveBeenCalledTimes(1)
188
+ releaseSelect()
189
+ await Promise.all([select1, select2])
190
+ })
191
+ })