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

@@ -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,