feat: 先进智能体功能上线
- 基于 agent-runtime 打造,默认 ReAct agent - 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力 - 全新 agent 编排界面,支持可视化便捷配置 agent - 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
This commit is contained in:
@@ -23,6 +23,7 @@ export {
|
||||
Copy,
|
||||
CornerDownLeft,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Expand,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"json-bigint": "catalog:",
|
||||
"mermaid": "^11.15.0",
|
||||
"qrcode": "catalog:",
|
||||
"tippy.js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user