feat: 先进智能体功能上线

- 基于 agent-runtime 打造,默认 ReAct agent
- 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力
- 全新 agent 编排界面,支持可视化便捷配置 agent
- 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
This commit is contained in:
2026-05-28 11:29:18 +08:00
parent 11e595b088
commit 1c205c3720
39 changed files with 3546 additions and 217 deletions

View File

@@ -23,6 +23,7 @@ export {
Copy,
CornerDownLeft,
Ellipsis,
EllipsisVertical,
Expand,
ExternalLink,
Eye,

View File

@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type {
DropdownMenuProps,
EasyFlowDropdownMenuItem as IDropdownMenuItem,
} from './interface';
import type {DropdownMenuProps, EasyFlowDropdownMenuItem as IDropdownMenuItem,} from './interface';
import {computed, ref} from 'vue';
import {Search} from '@easyflow-core/icons';
import {
DropdownMenu,
@@ -11,12 +12,29 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
} from '../../ui';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownMenu' });
const props = withDefaults(defineProps<Props>(), {});
const props = withDefaults(defineProps<Props>(), {
align: 'start',
searchEmptyText: '无匹配标签',
searchPlaceholder: '搜索标签',
});
const searchKeyword = ref('');
const filteredMenus = computed(() => {
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
if (!props.searchable || !keyword) {
return props.menus;
}
return props.menus.filter((menu) =>
menu.label.toLocaleLowerCase().includes(keyword),
);
});
function handleItemClick(menu: IDropdownMenuItem) {
if (menu.disabled) {
@@ -27,22 +45,50 @@ function handleItemClick(menu: IDropdownMenuItem) {
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger class="flex h-full items-center gap-1">
<DropdownMenuTrigger
:aria-label="triggerLabel"
class="flex h-full items-center gap-1"
>
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent :align="align" :class="contentClass">
<div v-if="searchable" class="sticky top-0 z-10 bg-popover p-1">
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-[hsl(var(--text-muted))]"
/>
<Input
v-model="searchKeyword"
:placeholder="searchPlaceholder"
class="h-8 rounded-md border-[hsl(var(--line-subtle))] bg-[hsl(var(--surface-subtle))] py-1 pl-8 pr-2 text-xs"
@keydown.stop
/>
</div>
</div>
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.value">
<template v-for="menu in filteredMenus" :key="menu.value">
<DropdownMenuItem
:disabled="menu.disabled"
:class="{
'bg-[hsl(var(--nav-item-active))] text-[hsl(var(--nav-item-active-foreground))] font-medium':
menu.active,
}"
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
@click="handleItemClick(menu)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
{{ menu.label }}
<span class="min-w-0 flex-1 truncate">
{{ menu.label }}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
</template>
<div
v-if="filteredMenus.length === 0"
class="px-3 py-5 text-center text-xs text-[hsl(var(--text-muted))]"
>
{{ searchEmptyText }}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,6 +1,10 @@
import type { Component } from 'vue';
import type {Component} from 'vue';
interface EasyFlowDropdownMenuItem {
/**
* @zh_CN 是否为当前选中项
*/
active?: boolean;
disabled?: boolean;
/**
* @zh_CN 点击事件处理
@@ -26,7 +30,31 @@ interface EasyFlowDropdownMenuItem {
}
interface DropdownMenuProps {
/**
* @zh_CN 菜单对齐方向
*/
align?: 'center' | 'end' | 'start';
/**
* @zh_CN 菜单浮层样式
*/
contentClass?: any;
menus: EasyFlowDropdownMenuItem[];
/**
* @zh_CN 搜索无结果文案
*/
searchEmptyText?: string;
/**
* @zh_CN 搜索占位文案
*/
searchPlaceholder?: string;
/**
* @zh_CN 是否显示菜单搜索框
*/
searchable?: boolean;
/**
* @zh_CN 触发按钮的无障碍标签
*/
triggerLabel?: string;
}
export type { DropdownMenuProps, EasyFlowDropdownMenuItem };

View File

@@ -1,18 +1,27 @@
<script lang="ts" setup>
import type { DropdownMenuProps } from '@easyflow-core/shadcn-ui';
import type {DropdownMenuProps} from '@easyflow-core/shadcn-ui';
import {EasyFlowDropdownMenu} from '@easyflow-core/shadcn-ui';
import { ChevronDown } from '@easyflow-core/icons';
import { EasyFlowDropdownMenu } from '@easyflow-core/shadcn-ui';
import {EllipsisVertical} from '@easyflow-core/icons';
defineProps<DropdownMenuProps>();
</script>
<template>
<EasyFlowDropdownMenu :menus="menus" :modal="false">
<EasyFlowDropdownMenu
:menus="menus"
align="end"
content-class="max-h-[min(70vh,420px)] min-w-56 overflow-y-auto"
:modal="false"
search-empty-text="无匹配标签"
search-placeholder="搜索标签"
searchable
trigger-label="查看标签页"
>
<div
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92]"
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
>
<ChevronDown class="size-4" />
<EllipsisVertical class="size-4" />
</div>
</EasyFlowDropdownMenu>
</template>

View File

@@ -36,6 +36,7 @@
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"mermaid": "^11.15.0",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",

View File

@@ -24,6 +24,11 @@ const incremarkOptions = {
htmlTree: false,
math: true,
};
const codeBlockConfigs = {
mermaid: {
takeOver: true,
},
};
const previousContent = ref('');
watch(
@@ -33,7 +38,9 @@ watch(
if (import.meta.env.DEV && streaming) {
const startsWithPrevious = content.startsWith(previous);
console.debug('[ChatTimeMarkdown] streaming update', {
deltaLength: startsWithPrevious ? content.length - previous.length : null,
deltaLength: startsWithPrevious
? content.length - previous.length
: null,
length: content.length,
previousLength: previous.length,
preview: content.slice(-160).replaceAll('\n', '\\n'),
@@ -50,6 +57,7 @@ watch(
<div class="chat-time-markdown">
<ThemeProvider :theme="isDark ? 'dark' : 'default'">
<IncremarkContent
:code-block-configs="codeBlockConfigs"
:content="markdownContent"
:incremark-options="incremarkOptions"
:is-finished="isFinished"
@@ -290,6 +298,7 @@ watch(
}
.chat-time-markdown :deep(.incremark-code) {
position: relative;
max-width: 100%;
margin: 1em 0;
overflow: hidden;
@@ -300,10 +309,52 @@ watch(
}
.chat-time-markdown :deep(.incremark-code .code-header) {
padding: 8px 12px;
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
min-height: 0;
padding: 0;
background: transparent;
border: 0;
}
.chat-time-markdown :deep(.incremark-code .code-header .language) {
display: none;
}
.chat-time-markdown :deep(.incremark-code .code-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle) / 0.72);
border-bottom: 1px solid hsl(var(--divider-faint) / 0.72);
background: hsl(var(--surface-subtle) / 0.68);
border: 0;
border-radius: 4px;
opacity: 0.64;
transition:
background-color 0.16s ease,
color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.incremark-code .code-btn:hover:not(:disabled)) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
opacity: 1;
}
.chat-time-markdown :deep(.incremark-code .code-btn svg),
.chat-time-markdown :deep(.incremark-mermaid .code-btn svg) {
width: 14px;
height: 14px;
}
.chat-time-markdown :deep(.incremark-code .code-btn:focus-visible) {
outline: 2px solid hsl(var(--primary) / 0.42);
outline-offset: 2px;
}
.chat-time-markdown :deep(.incremark-code .code-content) {
@@ -317,6 +368,10 @@ watch(
border-radius: 0;
}
.chat-time-markdown :deep(.incremark-code .code-content pre) {
padding-right: 36px;
}
.chat-time-markdown :deep(.shiki),
.chat-time-markdown :deep(.shiki code) {
background: transparent !important;
@@ -342,13 +397,121 @@ watch(
font-size: 1em;
}
.chat-time-markdown :deep(.mermaid),
.chat-time-markdown :deep([class*='mermaid']) {
.chat-time-markdown :deep(svg.mermaid),
.chat-time-markdown
:deep(
.mermaid:not(
.incremark-mermaid,
.mermaid-header,
.mermaid-actions,
.mermaid-content,
.mermaid-loading,
.mermaid-source-code,
.mermaid-svg
)
) {
max-width: 100%;
margin: 1em 0;
overflow: auto;
}
.chat-time-markdown :deep(.incremark-mermaid) {
position: relative;
max-width: 100%;
margin: 1em 0;
overflow: hidden;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 12px;
}
.chat-time-markdown :deep(.mermaid-header) {
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
min-height: 0;
padding: 0;
background: transparent;
border: 0;
}
.chat-time-markdown :deep(.mermaid-header .language) {
display: none;
}
.chat-time-markdown :deep(.mermaid-actions) {
gap: 2px;
padding: 1px;
background: hsl(var(--surface-subtle) / 0.68);
border: 0;
border-radius: 5px;
opacity: 0.68;
transition:
background-color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.mermaid-actions:hover) {
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
opacity: 1;
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn) {
width: 18px;
height: 18px;
color: hsl(var(--text-muted));
border-radius: 4px;
transition:
background-color 0.16s ease,
color 0.16s ease,
opacity 0.16s ease;
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn:hover:not(:disabled)) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.86);
}
.chat-time-markdown :deep(.incremark-mermaid .code-btn:focus-visible) {
outline: 2px solid hsl(var(--primary) / 0.42);
outline-offset: 2px;
}
.chat-time-markdown :deep(.mermaid-content) {
min-height: 96px;
margin: 0;
padding: 0 16px 16px;
overflow: auto;
}
.chat-time-markdown :deep(.mermaid-loading) {
color: hsl(var(--text-muted));
}
.chat-time-markdown :deep(.mermaid-source-code) {
margin: 0;
padding: 0 52px 0 0;
overflow: auto;
color: hsl(var(--text-strong));
background: transparent;
border: 0;
border-radius: 0;
}
.chat-time-markdown :deep(.mermaid-svg) {
display: flex;
justify-content: center;
min-width: 0;
overflow: auto;
}
.chat-time-markdown :deep(.mermaid-svg svg) {
max-width: 100%;
height: auto;
}
.chat-time-markdown :deep(svg) {
max-width: 100%;
height: auto;

View File

@@ -0,0 +1,37 @@
import {mount} from '@vue/test-utils';
import {nextTick} from 'vue';
import {describe, expect, it, vi} from 'vitest';
import ChatTimeMarkdown from '../ChatTimeMarkdown.vue';
vi.mock('@easyflow-core/preferences', () => ({
usePreferences: () => ({
isDark: false,
}),
}));
describe('ChatTimeMarkdown', () => {
it('renders mermaid code fences with the shared mermaid block', async () => {
const wrapper = mount(ChatTimeMarkdown, {
props: {
content: [
'```mermaid',
'flowchart TD',
'A[开始] --> B[结束]',
'```',
].join('\n'),
},
});
await nextTick();
expect(wrapper.find('.incremark-mermaid').exists()).toBe(true);
expect(wrapper.find('.mermaid-header').text().toLowerCase()).toContain(
'mermaid',
);
expect(wrapper.find('.mermaid-source-code').text()).toContain(
'flowchart TD',
);
});
});

View File

@@ -156,25 +156,24 @@ describe('chat timeline builder', () => {
}
});
it('shows no compression needed when memory compression produced no compressed event', () => {
it('removes memory compression status when compression produced no compressed event', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
label: '正在整理上下文',
phase: 'started',
status: 'running',
statusKey: 'memory-compression',
});
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
compressed: false,
label: '已整理上下文',
label: '无需压缩上下文',
phase: 'completed',
status: 'done',
statusKey: 'memory-compression',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('无需压缩上下文');
expect(items[0].presentation).toBe('separator');
expect(items[0].status).toBe('done');
expect(items[0].statusKey).toBe('memory-compression');
}
expect(items).toHaveLength(0);
});
it('ends current thinking before showing knowledge retrieval status', () => {
@@ -227,6 +226,24 @@ describe('chat timeline builder', () => {
expect(items).toHaveLength(0);
});
it('keeps AgentScope context reload hidden without showing a tool card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-context-reload',
toolName: 'context_reload',
input: { working_context_offload_uuid: 'context-id' },
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-context-reload',
toolName: 'context_reload',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(0);
});
it('ignores anonymous tool call events instead of rendering a fallback card', () => {
const items: ChatTimelineItem[] = [];

View File

@@ -38,7 +38,9 @@ function normalizeToolName(value?: string) {
function isHiddenToolName(toolName?: string) {
const normalizedName = normalizeToolName(toolName);
return (
normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__'
normalizedName === 'retrieve_knowledge' ||
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
);
}
@@ -178,6 +180,15 @@ function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
);
}
function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
const index = items.findIndex(
(item) => item.type === 'status' && item.statusKey === statusKey,
);
if (index >= 0) {
items.splice(index, 1);
}
}
function doneStatusLabel(item: ChatTimelineStatusItem) {
if (item.statusKey === 'knowledge-retrieval') {
return '已检索知识库';
@@ -453,17 +464,20 @@ export const ChatTimelineBuilder = {
payload?.status === 'done' || payload?.phase === 'completed'
? 'done'
: 'running';
const statusKey = payload?.statusKey || 'memory-compression';
finishAssistantMessage(items, false);
if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey);
return;
}
const label =
status === 'running'
? payload?.label || '正在整理上下文'
: payload?.compressed === false
? '无需压缩上下文'
: payload?.label || '已整理上下文';
: payload?.label || '已整理上下文';
upsertStatus(items, {
label,
status,
statusKey: payload?.statusKey || 'memory-compression',
statusKey,
presentation: 'separator',
tone: 'muted',
});

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import {computed} from 'vue';
import {useRoute} from 'vue-router';
import { useContentMaximize, useTabs } from '@easyflow/hooks';
import { preferences } from '@easyflow/preferences';
import { useTabbarStore } from '@easyflow/stores';
import {useContentMaximize, useTabs} from '@easyflow/hooks';
import {preferences} from '@easyflow/preferences';
import {useTabbarStore} from '@easyflow/stores';
import { TabsToolMore, TabsToolScreen, TabsView } from '@easyflow-core/tabs-ui';
import {TabsToolMore, TabsToolScreen, TabsView} from '@easyflow-core/tabs-ui';
import { useTabbar } from './use-tabbar';
import {useTabbar} from './use-tabbar';
defineOptions({
name: 'LayoutTabbar',
@@ -30,13 +30,17 @@ const {
} = useTabbar();
const menus = computed(() => {
const tab = tabbarStore.getTabByKey(currentActive.value);
const menus = createContextMenus(tab);
return menus.map((item) => {
return (currentTabs.value || []).map((tab) => {
const key = tab.key as string;
const title =
(tab.meta?.newTabTitle || tab.meta?.title || tab.name || tab.path) as
| string
| undefined;
return {
...item,
label: item.text,
value: item.key,
active: key === currentActive.value,
handler: () => handleClick(key),
label: title || key,
value: key,
};
});
});

View File

@@ -1,13 +1,11 @@
import type { RouteLocationNormalizedGeneric } from 'vue-router';
import type {TabDefinition} from '@easyflow/types';
import type { TabDefinition } from '@easyflow/types';
import type {IContextMenuItem} from '@easyflow-core/tabs-ui';
import type { IContextMenuItem } from '@easyflow-core/tabs-ui';
import {computed, ref, watch} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useContentMaximize, useTabs } from '@easyflow/hooks';
import {useContentMaximize, useTabs} from '@easyflow/hooks';
import {
ArrowLeftToLine,
ArrowRightLeft,
@@ -21,9 +19,9 @@ import {
RotateCw,
X,
} from '@easyflow/icons';
import { $t, useI18n } from '@easyflow/locales';
import { getTabKey, useAccessStore, useTabbarStore } from '@easyflow/stores';
import { filterTree } from '@easyflow/utils';
import {$t, useI18n} from '@easyflow/locales';
import {getTabKey, useAccessStore, useTabbarStore} from '@easyflow/stores';
import {filterTree} from '@easyflow/utils';
export function useTabbar() {
const router = useRouter();
@@ -52,7 +50,7 @@ export function useTabbar() {
});
const { locale } = useI18n();
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
const currentTabs = ref<TabDefinition[]>();
watch(
[
() => tabbarStore.getTabs,
@@ -99,7 +97,7 @@ export function useTabbar() {
}
}
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
function wrapperTabLocale(tab: TabDefinition) {
const navTitle = tab?.meta?.navTitle as string | undefined;
return {
...tab,

View File

@@ -120,6 +120,25 @@ describe('chat-time timeline builder', () => {
});
});
it('does not render built-in context reload tools as normal tool cards', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'context_reload',
toolCallId: 'context-reload-1',
value: '{"working_context_offload_uuid":"context-id"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
name: 'context_reload',
result: '{"messages":[]}',
toolCallId: 'context-reload-1',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
});
it('does not render anonymous internal tool calls as normal tool cards', () => {
const items: any[] = [];
@@ -254,6 +273,114 @@ describe('chat-time history mapper', () => {
});
});
it('skips internal tools when restoring OpenAI-style structured history', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"query":"暑假安排"}',
name: 'retrieve_knowledge',
},
id: 'knowledge-1',
},
{
function: {
arguments: '{"working_context_offload_uuid":"context-id"}',
name: 'context_reload',
},
id: 'context-1',
},
{
function: {
arguments: '{"text":"partial"}',
name: '__fragment__',
},
id: 'fragment-1',
},
{
function: {
arguments: '{"query":"java"}',
name: 'search_docs',
},
id: 'tool-1',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
tool_call_id: 'knowledge-1',
},
{
content: '{"messages":[]}',
role: 'tool',
tool_call_id: 'context-1',
},
{
content: '{"ok":true}',
role: 'tool',
tool_call_id: 'fragment-1',
},
{
content: '{"hits":2}',
role: 'tool',
tool_call_id: 'tool-1',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
arguments: '{"query":"java"}',
name: 'search_docs',
result: '{"hits":2}',
role: 'tool',
toolCallId: 'tool-1',
});
});
it('skips nameless tool result records when restoring history', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
content: '{"hits":1}',
contentPayload: {
result: '{"hits":1}',
toolCallId: 'knowledge-1',
},
created: 100,
id: 'tool-record',
senderRole: 'tool',
},
{
content: '最终回答',
created: 101,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
content: '最终回答',
role: 'assistant',
});
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{

View File

@@ -19,13 +19,31 @@ type ChatTimeToolMeta = {
function isHiddenToolName(value?: string) {
const normalized = normalizePlainText(value).trim().toLowerCase();
return normalized === 'retrieve_knowledge' || normalized === '__fragment__';
return (
normalized === 'retrieve_knowledge' ||
normalized === 'context_reload' ||
normalized === '__fragment__'
);
}
function isBlankToolName(value?: string) {
return !normalizePlainText(value).trim();
}
function normalizeToolCallId(value: Record<string, any>) {
return normalizePlainText(value.id ?? value.toolCallId ?? value.tool_call_id);
}
function normalizeToolCallName(value: Record<string, any>) {
const fn = toObjectRecord(value.function);
return normalizePlainText(value.name ?? value.toolName ?? fn.name);
}
function normalizeToolCallArguments(value: Record<string, any>) {
const fn = toObjectRecord(value.function);
return normalizePayloadValue(value.arguments ?? fn.arguments);
}
/**
* 聊天时间线实时构建器。
*/
@@ -623,6 +641,9 @@ function createToolItemFromChain(
if (!toolCallId && !name && !argumentsValue) {
return null;
}
if (isBlankToolName(name)) {
return null;
}
return createToolItem({
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
created,
@@ -655,6 +676,9 @@ function createToolItemFromStructuredMessage(
if (isHiddenToolName(toolMeta?.name || toolName)) {
return null;
}
if (isBlankToolName(toolMeta?.name || toolName)) {
return null;
}
const result = normalizePayloadValue(rawMessage.content);
return createToolItem({
arguments: toolMeta?.arguments,
@@ -680,6 +704,9 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
if (isHiddenToolName(name)) {
return null;
}
if (isBlankToolName(name)) {
return null;
}
const toolCallId = normalizePlainText(
payload.toolCallId ?? payload.tool_call_id ?? record.id,
);
@@ -768,13 +795,13 @@ function collectToolMeta(
) {
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
for (const toolCall of toolCalls) {
const toolCallId = normalizePlainText(toolCall.id);
const toolCallId = normalizeToolCallId(toolCall);
if (!toolCallId) {
continue;
}
toolMetaMap.set(toolCallId, {
arguments: normalizePayloadValue(toolCall.arguments),
name: normalizePlainText(toolCall.name ?? toolCall.toolName),
arguments: normalizeToolCallArguments(toolCall),
name: normalizeToolCallName(toolCall),
});
}
}