@veltra/ai 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/transports/openai.js +52 -6
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +31 -1
- package/dist/chat/use-chat.d.ts +31 -1
- package/dist/chat/use-chat.js +33 -1
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +17 -4
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +75 -20
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +15 -0
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/style.d.ts +1 -0
- package/dist/components/ai-chat/style.js +1 -0
- package/dist/components/ai-chat/style2.css +28 -2
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/types/ai-chat.d.ts +11 -2
- package/package.json +1 -1
- package/src/chat/__test__/use-chat.test.ts +67 -0
- package/src/chat/transports/__test__/openai.test.ts +140 -0
- package/src/chat/transports/openai.ts +73 -7
- package/src/chat/types.ts +31 -0
- package/src/chat/use-chat.ts +39 -1
- package/src/components/ai-chat/__test__/ai-chat.test.ts +121 -1
- package/src/components/ai-chat/ai-chat.vue +13 -0
- package/src/components/ai-chat/chat-input.vue +64 -3
- package/src/components/ai-chat/message-list.vue +22 -0
- package/src/components/ai-chat/style.scss +31 -2
- package/src/components/ai-chat/style.ts +1 -0
- package/src/types/ai-chat.ts +11 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"side-panel.js","names":[],"sources":["../../../src/components/ai-chat/side-panel.vue"],"sourcesContent":["<template>\n <aside :class=\"cls.e('panel')\">\n <div :class=\"cls.e('panel-card')\">\n <div :class=\"cls.e('panel-header')\">\n <UIcon :class=\"[cls.e('panel-icon'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"cls.e('panel-title')\">{{ panelTitle }}</span>\n <UIcon :class=\"cls.e('panel-close')\" title=\"关闭面板\" @click=\"emit('close')\">\n <Close />\n </UIcon>\n </div>\n\n <UScroll :class=\"cls.e('panel-body')\">\n <!-- render 组件与工具卡片同一契约(ChatToolRenderProps),可随 toolCall 状态实时更新 -->\n <component :is=\"tool.render\" v-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <pre v-else :class=\"cls.e('panel-fallback')\">{{ fallbackResult }}</pre>\n </UScroll>\n </div>\n </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UIcon, UScroll } from '@veltra/desktop'\nimport { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatSidePanel' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'close'): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-active' },\n running: { icon: Loading, class: 'is-active' },\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\n/** 面板标题:优先工具定义的 panelTitle(业务对象 + 动作),缺省回落到 label ?? name */\nconst panelTitle = computed(() => {\n const t = tool.value\n const pt = t?.panelTitle\n if (typeof pt === 'function') return pt(props.toolCall)\n return pt ?? t?.label ?? props.toolCall.name\n})\n\n/** 未提供 render 时的兜底展示(面板工具通常都应提供 render) */\nconst fallbackResult = 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</script>\n"],"mappings":";;;;;;;;;;;;EAiCA,MAAM,QAAQ;EAEd,MAAM,OAAO;EAEb,MAAM,KAAK,OAAO,WAAW;EAC7B,MAAM,MAAM,IAAI,OAAO,IAAI,SAAS;;EAGpC,MAAM,OAAO,eAAe,IAAI,MAAM,MAAM,MAAM,SAAS,KAAK;EAEhE,MAAM,cAAkE;GACtE,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,oBAAoB;IAAE,MAAM;IAAe,OAAO;GAAa;GAC/D,SAAS;IAAE,MAAM;IAAmB,OAAO;GAAa;GACxD,OAAO;IAAE,MAAM;IAAa,OAAO;GAAY;GAC/C,UAAU;IAAE,MAAM;IAAO,OAAO;GAAY;EAC9C;EAEA,MAAM,aAAa,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,QAAQ,OAAO;EACrF,MAAM,cAAc,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,SAAS,EAAE;;EAGlF,MAAM,aAAa,eAAe,KAAK,OAAO,QAAQ,WAAW,KAAK;;EAGtE,MAAM,aAAa,eAAe;GAChC,MAAM,IAAI,KAAK;GACf,MAAM,KAAK,GAAG;GACd,IAAI,OAAO,OAAO,YAAY,OAAO,GAAG,MAAM,QAAQ;GACtD,OAAO,MAAM,GAAG,SAAS,MAAM,SAAS;EAC1C,CAAC;;EAGD,MAAM,iBAAiB,eAAe;GACpC,MAAM,MAAM,MAAM,SAAS,SAAS,MAAM,SAAS,UAAU;GAC7D,IAAI;IACF,OAAO,KAAK,UAAU,KAAK,MAAM,GAAG,GAAG,MAAM,CAAC;GAChD,QAAQ;IACN,OAAO;GACT;EACF,CAAC;;GAzEC,OAAA,UAAA,GAAA,mBAkBQ,SAAA,EAlBA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,OAAA,CAAA,EAAA,GAClB,CAAA,mBAgBM,OAAA,EAhBA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,YAAA,CAAA,EAAA,GAChB,CAAA,mBAQM,OAAA,EARA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,cAAA,CAAA,EAAA,GAAA;IAChB,YAEQ,MAAA,KAAA,GAAA,EAFA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,YAAA,GAAgB,YAAA,KAAW,CAAA,EAAA,GAAA;
|
|
1
|
+
{"version":3,"file":"side-panel.js","names":[],"sources":["../../../src/components/ai-chat/side-panel.vue"],"sourcesContent":["<template>\n <aside :class=\"cls.e('panel')\">\n <div :class=\"cls.e('panel-card')\">\n <div :class=\"cls.e('panel-header')\">\n <UIcon :class=\"[cls.e('panel-icon'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"cls.e('panel-title')\">{{ panelTitle }}</span>\n <UIcon :class=\"cls.e('panel-close')\" title=\"关闭面板\" @click=\"emit('close')\">\n <Close />\n </UIcon>\n </div>\n\n <UScroll :class=\"cls.e('panel-body')\">\n <!-- render 组件与工具卡片同一契约(ChatToolRenderProps),可随 toolCall 状态实时更新 -->\n <component :is=\"tool.render\" v-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <pre v-else :class=\"cls.e('panel-fallback')\">{{ fallbackResult }}</pre>\n </UScroll>\n </div>\n </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UIcon, UScroll } from '@veltra/desktop'\nimport { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatSidePanel' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'close'): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-active' },\n running: { icon: Loading, class: 'is-active' },\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\n/** 面板标题:优先工具定义的 panelTitle(业务对象 + 动作),缺省回落到 label ?? name */\nconst panelTitle = computed(() => {\n const t = tool.value\n const pt = t?.panelTitle\n if (typeof pt === 'function') return pt(props.toolCall)\n return pt ?? t?.label ?? props.toolCall.name\n})\n\n/** 未提供 render 时的兜底展示(面板工具通常都应提供 render) */\nconst fallbackResult = 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</script>\n"],"mappings":";;;;;;;;;;;;EAiCA,MAAM,QAAQ;EAEd,MAAM,OAAO;EAEb,MAAM,KAAK,OAAO,WAAW;EAC7B,MAAM,MAAM,IAAI,OAAO,IAAI,SAAS;;EAGpC,MAAM,OAAO,eAAe,IAAI,MAAM,MAAM,MAAM,SAAS,KAAK;EAEhE,MAAM,cAAkE;GACtE,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,oBAAoB;IAAE,MAAM;IAAe,OAAO;GAAa;GAC/D,SAAS;IAAE,MAAM;IAAmB,OAAO;GAAa;GACxD,OAAO;IAAE,MAAM;IAAa,OAAO;GAAY;GAC/C,UAAU;IAAE,MAAM;IAAO,OAAO;GAAY;EAC9C;EAEA,MAAM,aAAa,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,QAAQ,OAAO;EACrF,MAAM,cAAc,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,SAAS,EAAE;;EAGlF,MAAM,aAAa,eAAe,KAAK,OAAO,QAAQ,WAAW,KAAK;;EAGtE,MAAM,aAAa,eAAe;GAChC,MAAM,IAAI,KAAK;GACf,MAAM,KAAK,GAAG;GACd,IAAI,OAAO,OAAO,YAAY,OAAO,GAAG,MAAM,QAAQ;GACtD,OAAO,MAAM,GAAG,SAAS,MAAM,SAAS;EAC1C,CAAC;;EAGD,MAAM,iBAAiB,eAAe;GACpC,MAAM,MAAM,MAAM,SAAS,SAAS,MAAM,SAAS,UAAU;GAC7D,IAAI;IACF,OAAO,KAAK,UAAU,KAAK,MAAM,GAAG,GAAG,MAAM,CAAC;GAChD,QAAQ;IACN,OAAO;GACT;EACF,CAAC;;GAzEC,OAAA,UAAA,GAAA,mBAkBQ,SAAA,EAlBA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,OAAA,CAAA,EAAA,GAClB,CAAA,mBAgBM,OAAA,EAhBA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,YAAA,CAAA,EAAA,GAChB,CAAA,mBAQM,OAAA,EARA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,cAAA,CAAA,EAAA,GAAA;IAChB,YAEQ,MAAA,KAAA,GAAA,EAFA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,YAAA,GAAgB,YAAA,KAAW,CAAA,EAAA,GAAA;KAC9C,SAAA,cAA8B,EAA9B,UAAA,GAAA,YAA8B,wBAAd,WAAA,KAAU,CAAA,EAAA,CAAA;;;IAE5B,mBAA2D,QAAA,EAApD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,aAAA,CAAA,EAAoB,GAAA,gBAAA,WAAA,KAAU,GAAA,CAAA;IACjD,YAEQ,MAAA,KAAA,GAAA;KAFA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,aAAA,CAAA;KAAiB,OAAM;KAAQ,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,OAAA;;KAC5D,SAAA,cAAS,CAAT,YAAS,MAAA,KAAA,CAAA,CAAA,CAAA;;;GAIb,GAAA,CAAA,GAAA,YAIU,MAAA,OAAA,GAAA,EAJA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,YAAA,CAAA,EAAA,GAAA;IACpB,SAAA,cAAmE,CAAnE,mBAAA,8DAAA,GACmC,KAAA,OAAM,UAAzC,UAAA,GAAA,YAAyE,wBAAzD,KAAA,MAAK,MAAM,GAAA;;KAAuB,aAAW,QAAA;IAC7D,GAAA,MAAA,GAAA,CAAA,WAAA,CAAA,MAAA,UAAA,GAAA,mBAAuE,OAAA;;KAA1D,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA;IAAuB,GAAA,gBAAA,eAAA,KAAc,GAAA,CAAA,EAAA,CAAA"}
|
|
@@ -5,6 +5,7 @@ import "@veltra/desktop/components/icon/style";
|
|
|
5
5
|
import "@veltra/desktop/components/input/style";
|
|
6
6
|
import "@veltra/desktop/components/layout/style";
|
|
7
7
|
import "@veltra/desktop/components/message/style";
|
|
8
|
+
import "@veltra/desktop/components/pop-confirm/style";
|
|
8
9
|
import "@veltra/desktop/components/scroll/style";
|
|
9
10
|
import "@veltra/desktop/components/select/style";
|
|
10
11
|
import "@veltra/styles/animations/shine.scss";
|
|
@@ -6,6 +6,7 @@ import "@veltra/desktop/components/icon/style";
|
|
|
6
6
|
import "@veltra/desktop/components/input/style";
|
|
7
7
|
import "@veltra/desktop/components/layout/style";
|
|
8
8
|
import "@veltra/desktop/components/message/style";
|
|
9
|
+
import "@veltra/desktop/components/pop-confirm/style";
|
|
9
10
|
import "@veltra/desktop/components/scroll/style";
|
|
10
11
|
import "@veltra/desktop/components/select/style";
|
|
11
12
|
import "@veltra/styles/animations/shine.scss";
|
|
@@ -787,7 +787,6 @@
|
|
|
787
787
|
font-size: var(--u-font-size-main-default);
|
|
788
788
|
font-family: inherit;
|
|
789
789
|
line-height: 1.6;
|
|
790
|
-
padding: var(--u-gap-small);
|
|
791
790
|
box-sizing: border-box;
|
|
792
791
|
}
|
|
793
792
|
.u-ai-chat__input::placeholder {
|
|
@@ -807,7 +806,8 @@
|
|
|
807
806
|
align-items: center;
|
|
808
807
|
flex-wrap: nowrap;
|
|
809
808
|
gap: var(--u-gap-small);
|
|
810
|
-
flex
|
|
809
|
+
flex: 1;
|
|
810
|
+
min-width: 0;
|
|
811
811
|
}
|
|
812
812
|
.u-ai-chat__input-toolbar-right {
|
|
813
813
|
display: flex;
|
|
@@ -860,6 +860,32 @@
|
|
|
860
860
|
.u-ai-chat__input-attach:hover {
|
|
861
861
|
color: var(--u-color-primary);
|
|
862
862
|
}
|
|
863
|
+
.u-ai-chat__input-clear-wrap {
|
|
864
|
+
flex-shrink: 0;
|
|
865
|
+
display: inline-flex;
|
|
866
|
+
line-height: 0;
|
|
867
|
+
}
|
|
868
|
+
.u-ai-chat__input-clear-wrap.is-disabled {
|
|
869
|
+
opacity: 0.4;
|
|
870
|
+
pointer-events: none;
|
|
871
|
+
}
|
|
872
|
+
.u-ai-chat__input-clear {
|
|
873
|
+
color: var(--u-text-color-assist);
|
|
874
|
+
cursor: pointer;
|
|
875
|
+
font-size: 18px;
|
|
876
|
+
}
|
|
877
|
+
.u-ai-chat__input-clear:hover {
|
|
878
|
+
color: var(--u-color-danger);
|
|
879
|
+
}
|
|
880
|
+
.u-ai-chat__input-usage {
|
|
881
|
+
min-width: 0;
|
|
882
|
+
overflow: hidden;
|
|
883
|
+
text-overflow: ellipsis;
|
|
884
|
+
white-space: nowrap;
|
|
885
|
+
font-size: var(--u-font-size-main-small);
|
|
886
|
+
color: var(--u-text-color-assist);
|
|
887
|
+
line-height: 1.4;
|
|
888
|
+
}
|
|
863
889
|
.u-ai-chat__input-stop {
|
|
864
890
|
display: block;
|
|
865
891
|
width: 10px;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/components/ai-chat/tool-call.vue"],"sourcesContent":["<template>\n <UCollapseItem\n v-model=\"isExpanded\"\n :class=\"[cls.e('tool-call'), bem.is(toolCall.status)]\"\n :destroy-on-collapse=\"destroyOnCollapse\"\n @change=\"markUserToggled\"\n >\n <template #header>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"[cls.e('tool-call-name'), isActive && 'u-shine']\">\n {{ tool?.label ?? toolCall.name }}\n </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 @keydown.enter.stop\n @keydown.space.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 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->\n <div v-if=\"isPanelTool\" :class=\"cls.e('tool-call-panel-entry')\">\n <span :class=\"cls.e('tool-call-panel-hint')\">内容展示在右侧面板</span>\n <UButton size=\"small\" text type=\"primary\" @click=\"di?.openPanel(toolCall.id)\">\n 查看面板\n </UButton>\n </div>\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool.render\" v-else-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</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\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/terminal) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\n/** UCollapseItem 的 change 仅在用户点击头部时触发(程序化 v-model 变更不触发) */\nconst markUserToggled = () => {\n userToggled = true\n}\n\n/** 面板工具:render 展示在右侧侧边面板,卡片 body 仅保留「查看面板」入口 */\nconst isPanelTool = computed(() => tool.value?.renderTo === 'panel')\n\n/** 完成后是否自动折叠:缺省面板工具折叠;有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(\n () => tool.value?.autoCollapse ?? (isPanelTool.value || !tool.value?.render)\n)\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-active' },\n running: { icon: Loading, class: 'is-active' },\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/** 进行中(pending/running):工具名带扫光效果 */\nconst isActive = computed(() => {\n return props.toolCall.status === 'pending' || props.toolCall.status === 'running'\n})\n\n/** 终态(成功/失败/拒绝) */\nconst isSettled = computed(() => {\n return ['success', 'error', 'rejected'].includes(props.toolCall.status)\n})\n\n/**\n * 折叠后卸载内容 DOM:仅终态且非面板工具时启用——\n * 进行中/待确认要保留内容状态(确认按钮、表单),面板工具的 body 是「查看面板」入口必须可达\n */\nconst destroyOnCollapse = computed(() => isSettled.value && !isPanelTool.value)\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 <UCollapseItem\n v-model=\"isExpanded\"\n :class=\"[cls.e('tool-call'), bem.is(toolCall.status)]\"\n :destroy-on-collapse=\"destroyOnCollapse\"\n @change=\"markUserToggled\"\n >\n <template #header>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"[cls.e('tool-call-name'), isActive && 'u-shine']\">\n {{ tool?.label ?? toolCall.name }}\n </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 @keydown.enter.stop\n @keydown.space.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 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->\n <div v-if=\"isPanelTool\" :class=\"cls.e('tool-call-panel-entry')\">\n <span :class=\"cls.e('tool-call-panel-hint')\">内容展示在右侧面板</span>\n <UButton size=\"small\" text type=\"primary\" @click=\"di?.openPanel(toolCall.id)\">\n 查看面板\n </UButton>\n </div>\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool.render\" v-else-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</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\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/terminal) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\n/** UCollapseItem 的 change 仅在用户点击头部时触发(程序化 v-model 变更不触发) */\nconst markUserToggled = () => {\n userToggled = true\n}\n\n/** 面板工具:render 展示在右侧侧边面板,卡片 body 仅保留「查看面板」入口 */\nconst isPanelTool = computed(() => tool.value?.renderTo === 'panel')\n\n/** 完成后是否自动折叠:缺省面板工具折叠;有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(\n () => tool.value?.autoCollapse ?? (isPanelTool.value || !tool.value?.render)\n)\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-active' },\n running: { icon: Loading, class: 'is-active' },\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/** 进行中(pending/running):工具名带扫光效果 */\nconst isActive = computed(() => {\n return props.toolCall.status === 'pending' || props.toolCall.status === 'running'\n})\n\n/** 终态(成功/失败/拒绝) */\nconst isSettled = computed(() => {\n return ['success', 'error', 'rejected'].includes(props.toolCall.status)\n})\n\n/**\n * 折叠后卸载内容 DOM:仅终态且非面板工具时启用——\n * 进行中/待确认要保留内容状态(确认按钮、表单),面板工具的 body 是「查看面板」入口必须可达\n */\nconst destroyOnCollapse = computed(() => isSettled.value && !isPanelTool.value)\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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-process.js","names":[],"sources":["../../../src/components/ai-chat/turn-process.vue"],"sourcesContent":["<template>\n <div :class=\"[cls.e('process'), bem.is('expanded', expanded)]\">\n <div :class=\"cls.e('process-header')\" @click=\"expanded = !expanded\">\n <UIcon :class=\"cls.e('process-icon')\"><CircleCheck /></UIcon>\n <span :class=\"cls.e('process-title')\">已完成</span>\n <UIcon :class=\"cls.e('process-chevron')\"><ArrowRight /></UIcon>\n </div>\n <!-- 折叠时完全卸载过程 DOM:历史轮次的思考/工具卡片可能很多,常驻挂载浪费渲染成本 -->\n <div v-if=\"expanded\" :class=\"cls.e('process-content')\">\n <MessageItem\n v-for=\"msg in messages\"\n :key=\"msg.id\"\n :message=\"msg\"\n :is-last=\"false\"\n :renderer-props=\"rendererProps\"\n @respond=\"(id, approved) => emit('respond', id, approved)\"\n />\n </div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UIcon } from '@veltra/desktop'\nimport { ArrowRight, CircleCheck } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { inject, ref } from 'vue'\n\nimport type { ChatMessage } from '../../chat/types'\nimport { AiChatDIKey } from './di'\nimport MessageItem from './message-item.vue'\n\ndefineOptions({ name: 'UAiChatTurnProcess' })\n\ndefineProps<{\n /** 本轮最终答案之前的过程消息(思考 + 中间文本 + 工具卡片) */\n messages: ChatMessage[]\n /** 透传给 MarkdownRender 的属性 */\n rendererProps?: Record<string, unknown>\n}>()\n\nconst emit = defineEmits<{ (e: 'respond', toolCallId: string, approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\nconst expanded = ref(false)\n</script>\n"],"mappings":";;;;;;;;;;;;;;;;EAwCA,MAAM,OAAO;EAGb,MAAM,MADK,OAAO,WACN,CAAA,EAAI,OAAO,IAAI,SAAS;EAEpC,MAAM,WAAW,IAAI,KAAK;;GA5CxB,OAAA,UAAA,GAAA,mBAiBM,OAAA,EAjBA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,SAAA,GAAa,MAAA,GAAA,CAAG,CAAC,GAAE,YAAa,SAAA,KAAQ,CAAA,CAAA,EAAA,GAAA;IACzD,mBAIM,OAAA;KAJA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA;KAAqB,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,SAAA,QAAQ,CAAI,SAAA;;KACxD,YAA6D,MAAA,KAAA,GAAA,EAArD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,cAAA,CAAA,EAAA,GAAA;
|
|
1
|
+
{"version":3,"file":"turn-process.js","names":[],"sources":["../../../src/components/ai-chat/turn-process.vue"],"sourcesContent":["<template>\n <div :class=\"[cls.e('process'), bem.is('expanded', expanded)]\">\n <div :class=\"cls.e('process-header')\" @click=\"expanded = !expanded\">\n <UIcon :class=\"cls.e('process-icon')\"><CircleCheck /></UIcon>\n <span :class=\"cls.e('process-title')\">已完成</span>\n <UIcon :class=\"cls.e('process-chevron')\"><ArrowRight /></UIcon>\n </div>\n <!-- 折叠时完全卸载过程 DOM:历史轮次的思考/工具卡片可能很多,常驻挂载浪费渲染成本 -->\n <div v-if=\"expanded\" :class=\"cls.e('process-content')\">\n <MessageItem\n v-for=\"msg in messages\"\n :key=\"msg.id\"\n :message=\"msg\"\n :is-last=\"false\"\n :renderer-props=\"rendererProps\"\n @respond=\"(id, approved) => emit('respond', id, approved)\"\n />\n </div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UIcon } from '@veltra/desktop'\nimport { ArrowRight, CircleCheck } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { inject, ref } from 'vue'\n\nimport type { ChatMessage } from '../../chat/types'\nimport { AiChatDIKey } from './di'\nimport MessageItem from './message-item.vue'\n\ndefineOptions({ name: 'UAiChatTurnProcess' })\n\ndefineProps<{\n /** 本轮最终答案之前的过程消息(思考 + 中间文本 + 工具卡片) */\n messages: ChatMessage[]\n /** 透传给 MarkdownRender 的属性 */\n rendererProps?: Record<string, unknown>\n}>()\n\nconst emit = defineEmits<{ (e: 'respond', toolCallId: string, approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\nconst expanded = ref(false)\n</script>\n"],"mappings":";;;;;;;;;;;;;;;;EAwCA,MAAM,OAAO;EAGb,MAAM,MADK,OAAO,WACN,CAAA,EAAI,OAAO,IAAI,SAAS;EAEpC,MAAM,WAAW,IAAI,KAAK;;GA5CxB,OAAA,UAAA,GAAA,mBAiBM,OAAA,EAjBA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,SAAA,GAAa,MAAA,GAAA,CAAG,CAAC,GAAE,YAAa,SAAA,KAAQ,CAAA,CAAA,EAAA,GAAA;IACzD,mBAIM,OAAA;KAJA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA;KAAqB,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,SAAA,QAAQ,CAAI,SAAA;;KACxD,YAA6D,MAAA,KAAA,GAAA,EAArD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,cAAA,CAAA,EAAA,GAAA;MAAkB,SAAA,cAAe,CAAf,YAAe,MAAA,WAAA,CAAA,CAAA,CAAA;;;KACrD,mBAAgD,QAAA,EAAzC,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,eAAA,CAAA,EAAmB,GAAA,OAAG,CAAA;KACzC,YAA+D,MAAA,KAAA,GAAA,EAAvD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,iBAAA,CAAA,EAAA,GAAA;MAAqB,SAAA,cAAc,CAAd,YAAc,MAAA,UAAA,CAAA,CAAA,CAAA;;;;IAEzD,mBAAA,6CAAA;IACW,SAAA,SAAX,UAAA,GAAA,mBASM,OAAA;;KATgB,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,iBAAA,CAAA;IAChC,GAAA,EAAA,UAAA,IAAA,GAAA,mBAOE,UAAA,MAAA,WANc,QAAA,WAAP,QAAG;KADZ,OAAA,UAAA,GAAA,YAOE,aAAA;MALC,KAAK,IAAI;MACT,SAAS;MACT,WAAS;MACT,kBAAgB,QAAA;MAChB,WAAO,OAAA,OAAA,OAAA,MAAG,IAAI,aAAa,KAAI,WAAY,IAAI,QAAQ"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
|
|
3
|
+
import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, ToolCallStatus } from "./chat/types.js";
|
|
4
4
|
import { ChatModel, ChatModelOption, ChatProvider, ChatReasoningLevel } from "./providers/types.js";
|
|
5
5
|
import { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed } from "./types/ai-chat.js";
|
|
6
6
|
import { AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction, AiOrbStatus } from "./types/ai-orb.js";
|
|
@@ -10,4 +10,4 @@ import { OpenAITransport, OpenAITransportOptions, createOpenAITransport } from "
|
|
|
10
10
|
import "./chat/index.js";
|
|
11
11
|
import { AskQuestionAnswer, AskQuestionArgs, AskQuestionItem, AskQuestionResult } from "./tools/ask-question/ask-question.js";
|
|
12
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, ChatTool, ChatToolCall, ChatToolContext, ChatToolRenderProps, ChatTransport, ChatTransportHandlers, ChatTransportRequest, OpenAITransport, OpenAITransportOptions, ToolCallStatus, _sfc_main as UAiChat, _sfc_main$1 as UAiOrb, UseChatOptions, type _AiChatExposed, createOpenAITransport, useChat };
|
|
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 };
|
package/dist/types/ai-chat.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
|
|
1
|
+
import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
|
|
2
2
|
import { ChatModelOption } from "../providers/types.js";
|
|
3
3
|
import { Ref } from "vue";
|
|
4
4
|
import { DeconstructValue } from "@veltra/utils";
|
|
@@ -36,6 +36,11 @@ interface AiChatProps {
|
|
|
36
36
|
maxAttachmentSize?: number;
|
|
37
37
|
/** 透传给内部 MarkdownRender 的属性 */
|
|
38
38
|
rendererProps?: Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* 是否展示 token 用量明细(缓存命中 / 未命中;缺字段不显示)。
|
|
41
|
+
* 默认 false:仅在拿到 usage 时显示会话累计「总 token」。接口未返回 usage 时不展示。
|
|
42
|
+
*/
|
|
43
|
+
tokenUsageDetail?: boolean;
|
|
39
44
|
}
|
|
40
45
|
interface AiChatEmits {
|
|
41
46
|
/** 消息列表变化 */
|
|
@@ -60,8 +65,12 @@ interface _AiChatExposed {
|
|
|
60
65
|
abort: () => void;
|
|
61
66
|
/** 重新生成最后一条 assistant 回复 */
|
|
62
67
|
regenerate: () => void;
|
|
63
|
-
/**
|
|
68
|
+
/** 清空消息、待发送队列与 token 统计(生成中会先中止) */
|
|
64
69
|
clear: () => void;
|
|
70
|
+
/** 当前会话累计 token(从未收到 usage 时为 null) */
|
|
71
|
+
tokenUsage: Ref<ChatTokenUsage | null>;
|
|
72
|
+
/** 最近一轮用户对话的 token(含工具多轮请求;该轮无 usage 时为 null) */
|
|
73
|
+
lastTurnUsage: Ref<ChatTokenUsage | null>;
|
|
65
74
|
/** 待发送消息队列(会话进行中提交的消息按序排队,收尾后 FIFO 自动接续) */
|
|
66
75
|
queue: Ref<ChatQueuedMessage[]>;
|
|
67
76
|
/** 立即执行队列中的某条:中断当前会话并插队为下一条 */
|
package/package.json
CHANGED
|
@@ -375,6 +375,8 @@ describe('useChat', () => {
|
|
|
375
375
|
|
|
376
376
|
expect(chat.messages.value).toHaveLength(0)
|
|
377
377
|
expect(chat.queue.value).toHaveLength(0)
|
|
378
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
379
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
378
380
|
})
|
|
379
381
|
|
|
380
382
|
it('请求携带 model 与 reasoningLevel', async () => {
|
|
@@ -567,4 +569,69 @@ describe('useChat', () => {
|
|
|
567
569
|
// 最终同步给父级的快照也包含 assistant 消息
|
|
568
570
|
expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
|
|
569
571
|
})
|
|
572
|
+
|
|
573
|
+
it('累计 onUsage,无 usage 时保持 null;clear 一并重置', async () => {
|
|
574
|
+
const emit = createEmit()
|
|
575
|
+
const requests: ChatTransportRequest[] = []
|
|
576
|
+
const transport: ChatTransport = (req, handlers) => {
|
|
577
|
+
requests.push(req)
|
|
578
|
+
if (requests.length === 1) {
|
|
579
|
+
handlers.onToolCall?.({ id: 'call-1', name: 'add', arguments: '{"a":1,"b":2}' })
|
|
580
|
+
handlers.onUsage?.({
|
|
581
|
+
promptTokens: 10,
|
|
582
|
+
completionTokens: 4,
|
|
583
|
+
totalTokens: 14,
|
|
584
|
+
cacheHitTokens: 6,
|
|
585
|
+
cacheMissTokens: 4
|
|
586
|
+
})
|
|
587
|
+
} else {
|
|
588
|
+
handlers.onTextDelta('3')
|
|
589
|
+
handlers.onUsage?.({
|
|
590
|
+
promptTokens: 20,
|
|
591
|
+
completionTokens: 2,
|
|
592
|
+
totalTokens: 22,
|
|
593
|
+
cacheHitTokens: 12,
|
|
594
|
+
cacheMissTokens: 8
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const tools: ChatTool[] = [
|
|
599
|
+
{ name: 'add', description: '加法', parameters: {}, execute: () => 3 }
|
|
600
|
+
]
|
|
601
|
+
const chat = useChat({ props: { transport, tools }, emit })
|
|
602
|
+
|
|
603
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
604
|
+
chat.send('算一下')
|
|
605
|
+
await waitFinish(emit)
|
|
606
|
+
|
|
607
|
+
expect(chat.lastTurnUsage.value).toEqual({
|
|
608
|
+
promptTokens: 30,
|
|
609
|
+
completionTokens: 6,
|
|
610
|
+
totalTokens: 36,
|
|
611
|
+
cacheHitTokens: 18,
|
|
612
|
+
cacheMissTokens: 12
|
|
613
|
+
})
|
|
614
|
+
expect(chat.tokenUsage.value).toEqual(chat.lastTurnUsage.value)
|
|
615
|
+
|
|
616
|
+
chat.send('再算')
|
|
617
|
+
await vi.waitFor(() => {
|
|
618
|
+
expect(emit.mock.calls.filter((call) => call[0] === 'finish')).toHaveLength(2)
|
|
619
|
+
})
|
|
620
|
+
expect(chat.lastTurnUsage.value?.totalTokens).toBe(22)
|
|
621
|
+
expect(chat.tokenUsage.value?.totalTokens).toBe(58)
|
|
622
|
+
expect(chat.tokenUsage.value?.cacheHitTokens).toBe(30)
|
|
623
|
+
|
|
624
|
+
chat.clear()
|
|
625
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
626
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('transport 不回调 onUsage 时不编造数字', async () => {
|
|
630
|
+
const emit = createEmit()
|
|
631
|
+
const chat = useChat({ props: { transport: textTransport('ok') }, emit })
|
|
632
|
+
chat.send('hi')
|
|
633
|
+
await waitFinish(emit)
|
|
634
|
+
expect(chat.tokenUsage.value).toBeNull()
|
|
635
|
+
expect(chat.lastTurnUsage.value).toBeNull()
|
|
636
|
+
})
|
|
570
637
|
})
|
|
@@ -7,6 +7,7 @@ const baseHandlers = (): ChatTransportHandlers => ({
|
|
|
7
7
|
onTextDelta: vi.fn(),
|
|
8
8
|
onReasoningDelta: vi.fn(),
|
|
9
9
|
onToolCall: vi.fn(),
|
|
10
|
+
onUsage: vi.fn(),
|
|
10
11
|
onError: vi.fn()
|
|
11
12
|
})
|
|
12
13
|
|
|
@@ -314,4 +315,143 @@ data: [DONE]
|
|
|
314
315
|
arguments: '{"y":2}'
|
|
315
316
|
})
|
|
316
317
|
})
|
|
318
|
+
|
|
319
|
+
it('请求体带 stream_options.include_usage', async () => {
|
|
320
|
+
const fetchMock = vi.fn(async () => {
|
|
321
|
+
return new Response('data: [DONE]\n\n', {
|
|
322
|
+
status: 200,
|
|
323
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
327
|
+
|
|
328
|
+
const transport = createOpenAITransport({
|
|
329
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
330
|
+
})
|
|
331
|
+
await transport(
|
|
332
|
+
{
|
|
333
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
334
|
+
signal: new AbortController().signal
|
|
335
|
+
},
|
|
336
|
+
baseHandlers()
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
|
340
|
+
stream: true,
|
|
341
|
+
stream_options: { include_usage: true }
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('解析末包 usage,含缓存命中 / 未命中,无 usage 不回调', async () => {
|
|
346
|
+
const sse = String.raw`data: {"choices":[{"delta":{"content":"hi"}}]}
|
|
347
|
+
|
|
348
|
+
data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":8},"prompt_cache_hit_tokens":8,"prompt_cache_miss_tokens":4}}
|
|
349
|
+
|
|
350
|
+
data: [DONE]
|
|
351
|
+
`
|
|
352
|
+
vi.stubGlobal(
|
|
353
|
+
'fetch',
|
|
354
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const transport = createOpenAITransport({
|
|
358
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
359
|
+
})
|
|
360
|
+
const handlers = baseHandlers()
|
|
361
|
+
await transport(
|
|
362
|
+
{
|
|
363
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
364
|
+
signal: new AbortController().signal
|
|
365
|
+
},
|
|
366
|
+
handlers
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
expect(handlers.onTextDelta).toHaveBeenCalledWith('hi')
|
|
370
|
+
expect(handlers.onUsage).toHaveBeenCalledTimes(1)
|
|
371
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
372
|
+
promptTokens: 12,
|
|
373
|
+
completionTokens: 3,
|
|
374
|
+
totalTokens: 15,
|
|
375
|
+
cacheHitTokens: 8,
|
|
376
|
+
cacheMissTokens: 4
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('仅有 cached_tokens 时由 prompt − 命中得到未命中;缺 usage 不编造', async () => {
|
|
381
|
+
const withCache = String.raw`data: {"choices":[{"delta":{"content":"a"}}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11,"prompt_tokens_details":{"cached_tokens":7}}}
|
|
382
|
+
|
|
383
|
+
data: [DONE]
|
|
384
|
+
`
|
|
385
|
+
const noUsage = String.raw`data: {"choices":[{"delta":{"content":"b"}}]}
|
|
386
|
+
|
|
387
|
+
data: [DONE]
|
|
388
|
+
`
|
|
389
|
+
const fetchMock = vi
|
|
390
|
+
.fn()
|
|
391
|
+
.mockResolvedValueOnce(new Response(withCache, { status: 200 }))
|
|
392
|
+
.mockResolvedValueOnce(new Response(noUsage, { status: 200 }))
|
|
393
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
394
|
+
|
|
395
|
+
const transport = createOpenAITransport({
|
|
396
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const withCacheHandlers = baseHandlers()
|
|
400
|
+
await transport(
|
|
401
|
+
{
|
|
402
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
403
|
+
signal: new AbortController().signal
|
|
404
|
+
},
|
|
405
|
+
withCacheHandlers
|
|
406
|
+
)
|
|
407
|
+
expect(withCacheHandlers.onUsage).toHaveBeenCalledWith({
|
|
408
|
+
promptTokens: 10,
|
|
409
|
+
completionTokens: 1,
|
|
410
|
+
totalTokens: 11,
|
|
411
|
+
cacheHitTokens: 7,
|
|
412
|
+
cacheMissTokens: 3
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const noUsageHandlers = baseHandlers()
|
|
416
|
+
await transport(
|
|
417
|
+
{
|
|
418
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
419
|
+
signal: new AbortController().signal
|
|
420
|
+
},
|
|
421
|
+
noUsageHandlers
|
|
422
|
+
)
|
|
423
|
+
expect(noUsageHandlers.onUsage).not.toHaveBeenCalled()
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('解析 Anthropic 风格 cache_read / cache_creation', async () => {
|
|
427
|
+
const sse = String.raw`data: {"choices":[{"delta":{"content":"ok"}}],"usage":{"prompt_tokens":20,"completion_tokens":2,"total_tokens":22,"cache_read_input_tokens":15,"cache_creation_input_tokens":3}}
|
|
428
|
+
|
|
429
|
+
data: [DONE]
|
|
430
|
+
`
|
|
431
|
+
vi.stubGlobal(
|
|
432
|
+
'fetch',
|
|
433
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
const transport = createOpenAITransport({
|
|
437
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
438
|
+
})
|
|
439
|
+
const handlers = baseHandlers()
|
|
440
|
+
await transport(
|
|
441
|
+
{
|
|
442
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
443
|
+
signal: new AbortController().signal
|
|
444
|
+
},
|
|
445
|
+
handlers
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
449
|
+
promptTokens: 20,
|
|
450
|
+
completionTokens: 2,
|
|
451
|
+
totalTokens: 22,
|
|
452
|
+
cacheHitTokens: 15,
|
|
453
|
+
cacheMissTokens: 5,
|
|
454
|
+
cacheCreationTokens: 3
|
|
455
|
+
})
|
|
456
|
+
})
|
|
317
457
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChatModelOption, ChatProvider } from '../../providers'
|
|
2
2
|
import type {
|
|
3
3
|
ChatMessage,
|
|
4
|
+
ChatTokenUsage,
|
|
4
5
|
ChatTool,
|
|
5
6
|
ChatTransport,
|
|
6
7
|
ChatTransportHandlers,
|
|
@@ -82,6 +83,58 @@ interface AccumulatedToolCall {
|
|
|
82
83
|
arguments: string
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
|
|
91
|
+
function readCount(value: unknown): number | undefined {
|
|
92
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
|
|
93
|
+
return Math.floor(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从 OpenAI 兼容 usage 对象解析。
|
|
98
|
+
* 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
|
|
99
|
+
*/
|
|
100
|
+
function parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {
|
|
101
|
+
if (!isRecord(raw)) return null
|
|
102
|
+
|
|
103
|
+
const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
|
|
104
|
+
const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
|
|
105
|
+
if (promptTokens == null && completionTokens == null) return null
|
|
106
|
+
|
|
107
|
+
const prompt = promptTokens ?? 0
|
|
108
|
+
const completion = completionTokens ?? 0
|
|
109
|
+
const usage: ChatTokenUsage = {
|
|
110
|
+
promptTokens: prompt,
|
|
111
|
+
completionTokens: completion,
|
|
112
|
+
totalTokens: readCount(raw.total_tokens) ?? prompt + completion
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
|
|
116
|
+
const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
|
|
117
|
+
const cacheHitTokens =
|
|
118
|
+
readCount(details?.cached_tokens) ??
|
|
119
|
+
readCount(inputDetails?.cached_tokens) ??
|
|
120
|
+
readCount(raw.prompt_cache_hit_tokens) ??
|
|
121
|
+
readCount(raw.cache_read_input_tokens) ??
|
|
122
|
+
readCount(raw.cached_tokens)
|
|
123
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
124
|
+
|
|
125
|
+
const cacheMissTokens =
|
|
126
|
+
readCount(raw.prompt_cache_miss_tokens) ??
|
|
127
|
+
(cacheHitTokens != null && promptTokens != null
|
|
128
|
+
? Math.max(0, promptTokens - cacheHitTokens)
|
|
129
|
+
: undefined)
|
|
130
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
131
|
+
|
|
132
|
+
const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
|
|
133
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
134
|
+
|
|
135
|
+
return usage
|
|
136
|
+
}
|
|
137
|
+
|
|
85
138
|
/**
|
|
86
139
|
* 逐行解析 SSE 流。
|
|
87
140
|
* 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
|
|
@@ -112,15 +165,21 @@ async function parseSSE(
|
|
|
112
165
|
const handleData = (data: string) => {
|
|
113
166
|
if (data === '[DONE]') return
|
|
114
167
|
|
|
115
|
-
let chunk:
|
|
168
|
+
let chunk: unknown
|
|
116
169
|
try {
|
|
117
170
|
chunk = JSON.parse(data)
|
|
118
171
|
} catch {
|
|
119
172
|
return
|
|
120
173
|
}
|
|
174
|
+
if (!isRecord(chunk)) return
|
|
175
|
+
|
|
176
|
+
const usage = parseOpenAIUsage(chunk.usage)
|
|
177
|
+
if (usage) handlers.onUsage?.(usage)
|
|
121
178
|
|
|
122
|
-
const
|
|
123
|
-
|
|
179
|
+
const choices = chunk.choices
|
|
180
|
+
const first = Array.isArray(choices) ? choices[0] : undefined
|
|
181
|
+
const delta = isRecord(first) ? first.delta : undefined
|
|
182
|
+
if (!isRecord(delta)) return
|
|
124
183
|
|
|
125
184
|
if (typeof delta.content === 'string' && delta.content) {
|
|
126
185
|
handlers.onTextDelta(delta.content)
|
|
@@ -134,7 +193,8 @@ async function parseSSE(
|
|
|
134
193
|
|
|
135
194
|
if (Array.isArray(delta.tool_calls)) {
|
|
136
195
|
for (const tc of delta.tool_calls) {
|
|
137
|
-
|
|
196
|
+
if (!isRecord(tc)) continue
|
|
197
|
+
const index = typeof tc.index === 'number' ? tc.index : 0
|
|
138
198
|
// 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
|
|
139
199
|
if (index > lastIndex && accumulated.size > 0) flushToolCalls()
|
|
140
200
|
lastIndex = Math.max(lastIndex, index)
|
|
@@ -144,9 +204,10 @@ async function parseSSE(
|
|
|
144
204
|
call = { id: '', name: '', arguments: '' }
|
|
145
205
|
accumulated.set(index, call)
|
|
146
206
|
}
|
|
147
|
-
if (tc.id) call.id = tc.id
|
|
148
|
-
|
|
149
|
-
if (
|
|
207
|
+
if (typeof tc.id === 'string') call.id = tc.id
|
|
208
|
+
const fn = isRecord(tc.function) ? tc.function : undefined
|
|
209
|
+
if (typeof fn?.name === 'string') call.name += fn.name
|
|
210
|
+
if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
|
|
150
211
|
}
|
|
151
212
|
}
|
|
152
213
|
}
|
|
@@ -240,10 +301,15 @@ export function createOpenAITransport(options: OpenAITransportOptions): OpenAITr
|
|
|
240
301
|
return
|
|
241
302
|
}
|
|
242
303
|
|
|
304
|
+
const extraStreamOptions = isRecord(globalBody?.stream_options)
|
|
305
|
+
? globalBody.stream_options
|
|
306
|
+
: undefined
|
|
243
307
|
const requestBody: Record<string, unknown> = {
|
|
244
308
|
...globalBody,
|
|
245
309
|
model: modelId,
|
|
246
310
|
stream: true,
|
|
311
|
+
// 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
|
|
312
|
+
stream_options: { ...extraStreamOptions, include_usage: true },
|
|
247
313
|
messages: toOpenAIMessages(messages, systemPrompt),
|
|
248
314
|
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
249
315
|
}
|
package/src/chat/types.ts
CHANGED
|
@@ -151,6 +151,32 @@ export interface ChatTransportRequest {
|
|
|
151
151
|
signal: AbortSignal
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* 一次模型请求的 token 用量。
|
|
156
|
+
* 仅包含接口实际返回的字段;缓存类数字缺省为 undefined,不补 0。
|
|
157
|
+
*/
|
|
158
|
+
export interface ChatTokenUsage {
|
|
159
|
+
/** 输入 token */
|
|
160
|
+
promptTokens: number
|
|
161
|
+
/** 输出 token */
|
|
162
|
+
completionTokens: number
|
|
163
|
+
/** 合计 token */
|
|
164
|
+
totalTokens: number
|
|
165
|
+
/**
|
|
166
|
+
* 缓存命中。
|
|
167
|
+
* 来源:`prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
|
|
168
|
+
* `cache_read_input_tokens` / `cached_tokens`。
|
|
169
|
+
*/
|
|
170
|
+
cacheHitTokens?: number
|
|
171
|
+
/**
|
|
172
|
+
* 缓存未命中。
|
|
173
|
+
* 来源:`prompt_cache_miss_tokens`;若无该字段但有命中与 prompt,则为 prompt − 命中。
|
|
174
|
+
*/
|
|
175
|
+
cacheMissTokens?: number
|
|
176
|
+
/** 写入缓存(Anthropic 风格 `cache_creation_input_tokens`),有才有 */
|
|
177
|
+
cacheCreationTokens?: number
|
|
178
|
+
}
|
|
179
|
+
|
|
154
180
|
/** transport 流式事件回调 */
|
|
155
181
|
export interface ChatTransportHandlers {
|
|
156
182
|
/** 文本增量 */
|
|
@@ -162,6 +188,11 @@ export interface ChatTransportHandlers {
|
|
|
162
188
|
* 流式参数片段由 transport 内部累积,参数完整后才通过该回调抛出。
|
|
163
189
|
*/
|
|
164
190
|
onToolCall?(call: { id: string; name: string; arguments: string }): void
|
|
191
|
+
/**
|
|
192
|
+
* 本次请求的 token 用量(流式末包或非流式 usage)。
|
|
193
|
+
* 接口未返回 usage 时不要调用,不要填 0 充数。
|
|
194
|
+
*/
|
|
195
|
+
onUsage?(usage: ChatTokenUsage): void
|
|
165
196
|
/** 请求错误 */
|
|
166
197
|
onError?(error: Error): void
|
|
167
198
|
}
|