feat(web): 合并知识库导航入口并调整视觉细节

This commit is contained in:
Wenjie Zhang 2026-04-06 21:04:19 +08:00
parent a62077b693
commit 2a52b368f0
15 changed files with 451 additions and 312 deletions

View File

@ -31,6 +31,9 @@
### 0.6.1 ### 0.6.1
<!-- 0.6.1 的内容请放在这里 --> <!-- 0.6.1 的内容请放在这里 -->
- 合并知识库导航入口:左侧导航仅保留“知识库”,文档知识库与图知识库在页面 header 中通过同一组轻量切换入口切换,保留原有列表与图谱内容区交互。
- 抽象页面轻量切换 header知识库与扩展管理页直接共用 `ViewSwitchHeader`,通过统一切换样式和 actions slot 收敛文档知识库、知识图谱、Tools、MCP、Subagents、Skills 等入口的信息层级;扩展管理各列表的刷新入口下沉到搜索框右侧,并统一搜索框与工具按钮的边框和圆角。
- 调整任务中心交互:入口移动到 GitHub 按钮下方,并将右侧抽屉展示改为居中弹窗,减少对主页面布局的占用。
- 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。 - 调整 backend Python 工作区依赖边界:将 `backend/package/yuxi` 明确为承载核心运行依赖的业务包,根 `backend/pyproject.toml` 仅保留工作区入口与开发/测试配置,减少依赖职责混淆。
- 将 `yuxi` 从 uv workspace 成员调整为 `backend/package` 下可独立构建的本地 Python 包backend 通过 path dependency 以已安装包形式发现依赖,移除对 `PYTHONPATH=/app/package` 的运行时耦合。 - 将 `yuxi` 从 uv workspace 成员调整为 `backend/package` 下可独立构建的本地 Python 包backend 通过 path dependency 以已安装包形式发现依赖,移除对 `PYTHONPATH=/app/package` 的运行时耦合。
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。 - 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。

View File

@ -41,15 +41,11 @@
min-width: 0; min-width: 0;
padding: 0; padding: 0;
} }
}
.search-box { .search-box :deep(.ant-input-affix-wrapper) {
padding: 8px 12px 0;
:deep(.ant-input-affix-wrapper) {
height: 28px; height: 28px;
padding: 0 12px; padding: 0 10px;
// border: none; border: 1px solid var(--gray-150);
border-radius: 8px; border-radius: 8px;
background-color: var(--gray-0); background-color: var(--gray-0);
box-shadow: none; box-shadow: none;
@ -57,7 +53,27 @@
&:hover, &:hover,
&:focus, &:focus,
&.ant-input-affix-wrapper-focused { &.ant-input-affix-wrapper-focused {
// border: none; border-color: var(--gray-200);
box-shadow: none;
}
}
}
.search-box {
padding: 8px 12px 0;
:deep(.ant-input-affix-wrapper) {
height: 28px;
padding: 0 10px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background-color: var(--gray-0);
box-shadow: none;
&:hover,
&:focus,
&.ant-input-affix-wrapper-focused {
border-color: var(--gray-200);
box-shadow: none; box-shadow: none;
} }
} }
@ -84,9 +100,9 @@
color: var(--gray-500); color: var(--gray-500);
} }
// :deep(.ant-input-outlined) { :deep(.ant-input-outlined) {
// border: none; border-color: var(--gray-150);
// } }
} }
.sidebar-tool { .sidebar-tool {
@ -145,8 +161,7 @@
&.active { &.active {
background-color: var(--gray-0); background-color: var(--gray-0);
border-color: @border-color; border-color: var(--main-500);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
} }
.item-header { .item-header {
@ -212,7 +227,7 @@
} }
.list-section-title { .list-section-title {
padding: 10px 14px 6px; padding: 0px 14px;
color: var(--gray-500); color: var(--gray-500);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;

View File

@ -588,7 +588,7 @@ const configSwitchOptions = computed(() => {
if (userStore.isAdmin) { if (userStore.isAdmin) {
options.push({ options.push({
label: '新建配置', label: '+ 新建配置',
value: CREATE_CONFIG_OPTION_VALUE value: CREATE_CONFIG_OPTION_VALUE
}) })
} }

View File

@ -2,12 +2,25 @@
<div ref="panelRef" class="agent-panel" :class="{ resizing: isResizing }"> <div ref="panelRef" class="agent-panel" :class="{ resizing: isResizing }">
<!-- 拖拽手柄 --> <!-- 拖拽手柄 -->
<div class="resize-handle" @pointerdown="startResize"></div> <div class="resize-handle" @pointerdown="startResize"></div>
<div class="panel-header"> <div class="panel-header" :class="{ 'is-compact': isCompactHeader }">
<div class="panel-title"> <div class="panel-header-main">
<FolderCode :size="20" class="header-icon" /> <div class="panel-title">
<span><strong>文件系统</strong></span> <span><strong>文件系统</strong></span>
</div>
<div class="window-actions">
<button
class="header-action-btn"
:title="isExpanded ? '恢复高度' : '向上展开'"
@click="emit('toggle-expand')"
>
<component :is="isExpanded ? ChevronsDownUp : ChevronsUpDown" :size="15" />
</button>
<button class="close-btn" @click="$emit('close')">
<X :size="18" />
</button>
</div>
</div> </div>
<div class="header-actions"> <div class="file-toolbar">
<button <button
class="header-action-btn" class="header-action-btn"
title="新建文件夹" title="新建文件夹"
@ -27,17 +40,6 @@
<button class="header-action-btn" title="刷新" @click="emitRefresh"> <button class="header-action-btn" title="刷新" @click="emitRefresh">
<RefreshCw :size="15" /> <RefreshCw :size="15" />
</button> </button>
<span class="header-divider"></span>
<button
class="header-action-btn"
:title="isExpanded ? '恢复高度' : '向上展开'"
@click="emit('toggle-expand')"
>
<component :is="isExpanded ? ChevronsDownUp : ChevronsUpDown" :size="15" />
</button>
<button class="close-btn" @click="$emit('close')">
<X :size="18" />
</button>
</div> </div>
</div> </div>
<input <input
@ -166,7 +168,6 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
Download, Download,
FolderCode,
FolderPlus, FolderPlus,
RefreshCw, RefreshCw,
Trash2, Trash2,
@ -239,6 +240,7 @@ const expandedKeys = ref([])
const deletingPaths = ref(new Set()) const deletingPaths = ref(new Set())
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH) const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const isCompactHeader = computed(() => panelWidth.value > 0 && panelWidth.value < 360)
const buildDisplayName = (fullPath) => { const buildDisplayName = (fullPath) => {
const normalized = String(fullPath || '').replace(/\/+$/, '') const normalized = String(fullPath || '').replace(/\/+$/, '')
@ -866,10 +868,41 @@ watch(useInlinePreview, (isInline) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
padding: 4px 16px; padding: 4px 16px;
height: 56px; min-height: 56px;
background: var(--gray-25); background: var(--gray-25);
flex-shrink: 0; flex-shrink: 0;
&.is-compact {
align-items: stretch;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
.panel-header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.file-toolbar {
order: 2;
width: 100%;
justify-content: flex-start;
padding: 4px;
border-right: none;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
}
}
}
.panel-header-main {
display: contents;
} }
.header-action-btn { .header-action-btn {
@ -902,26 +935,41 @@ watch(useInlinePreview, (isInline) => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
order: 1;
flex: 1;
min-width: 0;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: var(--gray-900); color: var(--gray-900);
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-icon { .header-icon {
flex-shrink: 0;
color: var(--gray-700); color: var(--gray-700);
} }
} }
.header-actions { .file-toolbar,
.window-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.header-divider { .file-toolbar {
width: 1px; order: 2;
height: 16px; padding-right: 8px;
background: var(--gray-300); border-right: 1px solid var(--gray-300);
margin: 0 4px; }
.window-actions {
order: 3;
flex-shrink: 0;
} }
.hidden-file-input { .hidden-file-input {

View File

@ -43,7 +43,8 @@ defineProps({
.header-container { .header-container {
background-color: var(--bg-sider); background-color: var(--bg-sider);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 10px 24px; padding: 8px 16px;
height: 50px;
border-bottom: 1px solid var(--gray-150); border-bottom: 1px solid var(--gray-150);
position: sticky; position: sticky;
top: 0; top: 0;
@ -52,6 +53,8 @@ defineProps({
.header-content { .header-content {
display: flex; display: flex;
width: 100%;
height: 100%;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@ -71,7 +74,7 @@ defineProps({
h1 { h1 {
margin: 0; margin: 0;
font-size: 18px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--gray-2000); color: var(--gray-2000);
} }

View File

@ -7,15 +7,23 @@
<!-- 左侧MCP 列表 --> <!-- 左侧MCP 列表 -->
<div class="sidebar-list"> <div class="sidebar-list">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="search-box"> <div class="sidebar-toolbar">
<a-input <div class="search-box">
v-model:value="searchQuery" <a-input
placeholder="搜索 MCP..." v-model:value="searchQuery"
allow-clear placeholder="搜索 MCP..."
class="search-input" allow-clear
> class="search-input"
<template #prefix><Search :size="14" class="text-muted" /></template> >
</a-input> <template #prefix><Search :size="14" class="text-muted" /></template>
</a-input>
</div>
<a-tooltip title="刷新 MCP">
<a-button class="sidebar-tool" :disabled="loading" @click="fetchServers">
<RotateCw :size="14" />
</a-button>
</a-tooltip>
</div> </div>
<!-- 统计信息 --> <!-- 统计信息 -->

View File

@ -6,15 +6,23 @@
<div class="layout-wrapper" :class="{ 'content-loading': loading }"> <div class="layout-wrapper" :class="{ 'content-loading': loading }">
<!-- 左侧技能列表 --> <!-- 左侧技能列表 -->
<div class="sidebar-list"> <div class="sidebar-list">
<div class="search-box"> <div class="sidebar-toolbar">
<a-input <div class="search-box">
v-model:value="searchQuery" <a-input
placeholder="搜索技能..." v-model:value="searchQuery"
allow-clear placeholder="搜索技能..."
class="search-input" allow-clear
> class="search-input"
<template #prefix><Search :size="14" class="text-muted" /></template> >
</a-input> <template #prefix><Search :size="14" class="text-muted" /></template>
</a-input>
</div>
<a-tooltip title="刷新 Skills">
<a-button class="sidebar-tool" :disabled="loading" @click="fetchSkills">
<RotateCw :size="14" />
</a-button>
</a-tooltip>
</div> </div>
<div class="list-container"> <div class="list-container">
@ -1211,20 +1219,17 @@ defineExpose({
.tree-container { .tree-container {
width: 240px; width: 240px;
border-right: 1px solid @border-color; border-right: 1px solid @border-color;
background-color: @bg-secondary;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
.tree-header { .tree-header {
padding: 10px 16px; padding: 10px 12px 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid @border-color;
background-color: var(--gray-50);
.label { .label {
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--gray-500); color: var(--gray-500);
text-transform: uppercase; text-transform: uppercase;
@ -1251,7 +1256,8 @@ defineExpose({
.tree-content { .tree-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px; height: 100%;
padding: 8px 0;
} }
} }
@ -1264,8 +1270,7 @@ defineExpose({
min-height: 0; min-height: 0;
.editor-header { .editor-header {
padding: 8px 16px; padding: 8px 16px 4px;
border-bottom: 1px solid @border-color;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -1327,7 +1332,7 @@ defineExpose({
height: 100%; height: 100%;
border: none; border: none;
resize: none; resize: none;
padding: 20px; padding: 16px;
font-family: 'Fira Code', 'Monaco', monospace; font-family: 'Fira Code', 'Monaco', monospace;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;

View File

@ -7,15 +7,23 @@
<!-- 左侧SubAgent 列表 --> <!-- 左侧SubAgent 列表 -->
<div class="sidebar-list"> <div class="sidebar-list">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="search-box"> <div class="sidebar-toolbar">
<a-input <div class="search-box">
v-model:value="searchQuery" <a-input
placeholder="搜索 SubAgent..." v-model:value="searchQuery"
allow-clear placeholder="搜索 SubAgent..."
class="search-input" allow-clear
> class="search-input"
<template #prefix><Search :size="14" class="text-muted" /></template> >
</a-input> <template #prefix><Search :size="14" class="text-muted" /></template>
</a-input>
</div>
<a-tooltip title="刷新 Subagents">
<a-button class="sidebar-tool" :disabled="loading" @click="fetchSubAgents">
<RotateCw :size="14" />
</a-button>
</a-tooltip>
</div> </div>
<!-- SubAgent 列表 --> <!-- SubAgent 列表 -->
@ -298,6 +306,7 @@ import {
Bot, Bot,
Pencil, Pencil,
Trash2, Trash2,
RotateCw,
Info, Info,
MessageSquare, MessageSquare,
FileText, FileText,

View File

@ -1,5 +1,13 @@
<template> <template>
<a-drawer :open="isOpen" :width="620" title="任务中心" placement="right" @close="handleClose"> <a-modal
:open="isOpen"
title="任务中心"
:width="680"
:footer="null"
:destroy-on-close="false"
class="task-center-modal"
@cancel="handleClose"
>
<p>提醒任务执行成功只代表任务已执行完成但是任务内部可能有问题已捕获请注意观察日志</p> <p>提醒任务执行成功只代表任务已执行完成但是任务内部可能有问题已捕获请注意观察日志</p>
<div class="task-center"> <div class="task-center">
<div class="task-toolbar"> <div class="task-toolbar">
@ -37,8 +45,8 @@
<div class="task-card-info"> <div class="task-card-info">
<div class="task-card-title">{{ task.name }}</div> <div class="task-card-title">{{ task.name }}</div>
<div class="task-card-meta"> <div class="task-card-meta">
<span class="task-card-id">#{{ formatTaskId(task.id) }}</span>
<span class="task-card-type">{{ taskTypeLabel(task.type) }}</span> <span class="task-card-type">{{ taskTypeLabel(task.type) }}</span>
<span class="task-card-id">#{{ formatTaskId(task.id) }}</span>
<span v-if="getTaskDuration(task)" class="task-card-duration">{{ <span v-if="getTaskDuration(task)" class="task-card-duration">{{
getTaskDuration(task) getTaskDuration(task)
}}</span> }}</span>
@ -107,7 +115,7 @@
</div> </div>
</div> </div>
</div> </div>
</a-drawer> </a-modal>
</template> </template>
<script setup> <script setup>
@ -349,7 +357,9 @@ function canCancel(task) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
height: 100%; max-height: min(70vh, 720px);
min-height: 0;
overflow: hidden;
} }
.task-toolbar { .task-toolbar {
@ -389,16 +399,20 @@ function canCancel(task) {
} }
.task-list { .task-list {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
overflow-y: auto;
padding-right: 4px;
} }
.task-card { .task-card {
background: var(--gray-0); background: var(--gray-0);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
border-radius: 10px; border-radius: 10px;
padding: 14px 16px; padding: 12px 16px;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -519,10 +533,7 @@ function canCancel(task) {
} }
.task-card-type { .task-card-type {
padding: 2px 6px; font-size: 12px;
border-radius: 4px;
background: var(--gray-100);
font-size: 11px;
} }
.task-card-duration { .task-card-duration {
@ -586,7 +597,7 @@ function canCancel(task) {
} }
.task-card-actions :deep(.ant-btn) { .task-card-actions :deep(.ant-btn) {
height: 28px; height: 24px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
color: var(--gray-500); color: var(--gray-500);

View File

@ -18,6 +18,12 @@
</a-input> </a-input>
</div> </div>
<a-tooltip title="刷新工具">
<a-button class="sidebar-tool" :disabled="loading" @click="fetchTools">
<RotateCw :size="14" />
</a-button>
</a-tooltip>
<a-tooltip :title="`当前分类:${currentCategoryLabel}`"> <a-tooltip :title="`当前分类:${currentCategoryLabel}`">
<a-dropdown trigger="click"> <a-dropdown trigger="click">
<a-button <a-button
@ -160,7 +166,16 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { Search, Wrench, Tag, Tags, FileText, List, SlidersHorizontal } from 'lucide-vue-next' import {
Search,
Wrench,
Tag,
Tags,
FileText,
List,
SlidersHorizontal,
RotateCw
} from 'lucide-vue-next'
import { toolApi } from '@/apis/tool_api' import { toolApi } from '@/apis/tool_api'
import { getToolIcon } from '@/components/ToolCallingResult/toolRegistry' import { getToolIcon } from '@/components/ToolCallingResult/toolRegistry'

View File

@ -0,0 +1,109 @@
<template>
<HeaderComponent :title="title" :loading="loading">
<template #behind-title>
<nav class="view-switcher" :aria-label="ariaLabel">
<slot name="options" :items="items" :active-key="activeKey" :select="selectItem">
<template v-for="item in items" :key="item.key">
<RouterLink
v-if="item.path"
:to="item.path"
class="switcher-item"
:class="{ active: activeKey === item.key }"
@click="selectItem(item)"
>
{{ item.label }}
</RouterLink>
<button
v-else
type="button"
class="switcher-item"
:class="{ active: activeKey === item.key }"
@click="selectItem(item)"
>
{{ item.label }}
</button>
</template>
</slot>
</nav>
</template>
<template #actions v-if="$slots.actions">
<slot name="actions"></slot>
</template>
</HeaderComponent>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import HeaderComponent from '@/components/HeaderComponent.vue'
defineProps({
title: {
type: String,
required: true
},
loading: {
type: Boolean,
default: false
},
activeKey: {
type: String,
required: true
},
items: {
type: Array,
required: true
},
ariaLabel: {
type: String,
default: '视图切换'
}
})
const emit = defineEmits(['update:activeKey', 'change'])
const selectItem = (item) => {
emit('update:activeKey', item.key)
emit('change', item)
}
</script>
<style scoped lang="less">
.view-switcher {
display: inline-flex;
align-items: center;
gap: 2px;
padding-left: 12px;
margin-left: 2px;
border-left: 1px solid var(--gray-200);
}
.switcher-item {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gray-700);
font-size: 15px;
font-weight: 500;
line-height: 1;
text-decoration: none;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
&:hover {
color: var(--main-color);
background-color: var(--gray-50);
}
&.active {
color: var(--main-color);
background-color: var(--main-40);
}
}
</style>

View File

@ -2,7 +2,7 @@
import { ref, reactive, onMounted, computed, provide } from 'vue' import { ref, reactive, onMounted, computed, provide } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router' import { RouterLink, RouterView, useRoute } from 'vue-router'
import { GithubOutlined } from '@ant-design/icons-vue' import { GithubOutlined } from '@ant-design/icons-vue'
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck, Blocks } from 'lucide-vue-next' import { Bot, LibraryBig, BarChart3, ClipboardList, Blocks } from 'lucide-vue-next'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useDatabaseStore } from '@/stores/database' import { useDatabaseStore } from '@/stores/database'
@ -113,20 +113,13 @@ const mainList = computed(() => {
if (userStore.isAdmin) { if (userStore.isAdmin) {
if (!isLiteMode) { if (!isLiteMode) {
items.push( items.push({
{ name: '知识库',
name: '图谱', path: '/database',
path: '/graph', activePaths: ['/database', '/graph'],
icon: Waypoints, icon: LibraryBig,
activeIcon: Waypoints activeIcon: LibraryBig
}, })
{
name: '知识库',
path: '/database',
icon: LibraryBig,
activeIcon: LibraryBig
}
)
} }
if (userStore.isSuperAdmin) { if (userStore.isSuperAdmin) {
@ -149,6 +142,11 @@ const mainList = computed(() => {
return items return items
}) })
const isNavItemActive = (item) => {
const activePaths = item.activePaths || [item.path]
return activePaths.some((path) => route.path === path || route.path.startsWith(`${path}/`))
}
// Provide settings modal methods to child components // Provide settings modal methods to child components
provide('settingsModal', { provide('settingsModal', {
openSettingsModal openSettingsModal
@ -171,35 +169,18 @@ provide('settingsModal', {
:to="item.path" :to="item.path"
v-show="!item.hidden" v-show="!item.hidden"
class="nav-item" class="nav-item"
:class="{ active: isNavItemActive(item) }"
active-class="active" active-class="active"
> >
<a-tooltip placement="right"> <a-tooltip placement="right">
<template #title>{{ item.name }}</template> <template #title>{{ item.name }}</template>
<component <component
class="icon" class="icon"
:is="route.path.startsWith(item.path) ? item.activeIcon : item.icon" :is="isNavItemActive(item) ? item.activeIcon : item.icon"
size="22" size="22"
/> />
</a-tooltip> </a-tooltip>
</RouterLink> </RouterLink>
<div
v-if="userStore.isAdmin"
class="nav-item task-center"
:class="{ active: isDrawerOpen }"
@click="taskerStore.openDrawer()"
>
<a-tooltip placement="right">
<template #title>任务中心</template>
<a-badge
:count="activeTaskCount"
:overflow-count="99"
class="task-center-badge"
size="small"
>
<CircleCheck class="icon" size="22" />
</a-badge>
</a-tooltip>
</div>
</div> </div>
<div class="fill"></div> <div class="fill"></div>
<div class="github nav-item"> <div class="github nav-item">
@ -213,6 +194,24 @@ provide('settingsModal', {
</a> </a>
</a-tooltip> </a-tooltip>
</div> </div>
<div
v-if="userStore.isAdmin"
class="nav-item task-center"
:class="{ active: isDrawerOpen }"
@click="taskerStore.openDrawer()"
>
<a-tooltip placement="right">
<template #title>任务中心</template>
<a-badge
:count="activeTaskCount"
:overflow-count="99"
class="task-center-badge"
size="small"
>
<ClipboardList class="icon" size="22" />
</a-badge>
</a-tooltip>
</div>
<!-- 用户信息组件 --> <!-- 用户信息组件 -->
<div class="nav-item user-info"> <div class="nav-item user-info">
<UserInfoComponent /> <UserInfoComponent />
@ -392,6 +391,8 @@ div.header,
display: none; display: none;
} }
&.task-center { &.task-center {
margin-bottom: 16px;
.task-center-badge { .task-center-badge {
width: 100%; width: 100%;
display: flex; display: flex;
@ -539,6 +540,10 @@ div.header,
} }
} }
} }
&.task-center {
margin-bottom: 0;
}
} }
} }
</style> </style>

View File

@ -1,10 +1,16 @@
<template> <template>
<div class="database-container layout-container"> <div class="database-container layout-container">
<HeaderComponent title="文档知识库" :loading="dbState.listLoading"> <ViewSwitchHeader
title="知识库"
:active-key="knowledgeActiveView"
:items="knowledgeViewItems"
:loading="dbState.listLoading"
aria-label="知识库视图切换"
>
<template #actions> <template #actions>
<a-button type="primary" @click="state.openNewDatabaseModel = true"> 新建知识库 </a-button> <a-button type="primary" @click="state.openNewDatabaseModel = true"> 新建知识库 </a-button>
</template> </template>
</HeaderComponent> </ViewSwitchHeader>
<a-modal <a-modal
:open="state.openNewDatabaseModel" :open="state.openNewDatabaseModel"
@ -240,7 +246,7 @@ import { useDatabaseStore } from '@/stores/database'
import { LockOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue' import { LockOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { typeApi } from '@/apis/knowledge_api' import { typeApi } from '@/apis/knowledge_api'
import HeaderComponent from '@/components/HeaderComponent.vue' import ViewSwitchHeader from '@/components/ViewSwitchHeader.vue'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue' import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue' import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
import ShareConfigForm from '@/components/ShareConfigForm.vue' import ShareConfigForm from '@/components/ShareConfigForm.vue'
@ -257,6 +263,12 @@ const databaseStore = useDatabaseStore()
// 使 store // 使 store
const { databases, state: dbState } = storeToRefs(databaseStore) const { databases, state: dbState } = storeToRefs(databaseStore)
const knowledgeActiveView = 'documents'
const knowledgeViewItems = [
{ key: 'documents', label: '文档知识库', path: '/database' },
{ key: 'graph', label: '知识图谱', path: '/graph' }
]
const state = reactive({ const state = reactive({
openNewDatabaseModel: false openNewDatabaseModel: false
}) })
@ -668,7 +680,7 @@ onMounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
.databases { .databases {
padding: 20px; padding: 12px 16px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px; gap: 16px;
@ -691,9 +703,9 @@ onMounted(() => {
.dbcard, .dbcard,
.database { .database {
width: 100%; width: 100%;
padding: 16px; padding: 8px 12px;
border-radius: 16px; border-radius: 8px;
height: 156px; height: 140px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -721,9 +733,9 @@ onMounted(() => {
margin-bottom: 14px; margin-bottom: 14px;
.icon { .icon {
width: 54px; width: 50px;
height: 54px; height: 50px;
font-size: 26px; font-size: 24px;
margin-right: 14px; margin-right: 14px;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -746,7 +758,7 @@ onMounted(() => {
} }
h3 { h3 {
font-size: 17px; font-size: 16px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.4; line-height: 1.4;

View File

@ -1,96 +1,66 @@
<template> <template>
<div class="extensions-view extension-page-root"> <div class="extensions-view extension-page-root">
<div class="extensions-header"> <ViewSwitchHeader
<a-tabs v-model:activeKey="activeTab" class="extensions-tabs"> v-model:active-key="activeTab"
<a-tab-pane key="tools" tab="工具" /> title="扩展管理"
<a-tab-pane key="mcp" tab="MCP 管理" /> :items="extensionTabs"
<a-tab-pane key="subagents" tab="Subagents 管理" /> aria-label="扩展管理视图切换"
<a-tab-pane key="skills" tab="Skills 管理" /> >
</a-tabs> <template #actions>
<div class="header-actions"> <div class="extension-header-actions">
<!-- Skills Tab 的按钮 --> <!-- Skills Tab 的按钮 -->
<template v-if="activeTab === 'skills'"> <template v-if="activeTab === 'skills'">
<a-button <a-button
@click="handleOpenRemoteInstall" @click="handleOpenRemoteInstall"
:disabled="skillsLoading || skillsImporting" :disabled="skillsLoading || skillsImporting"
class="lucide-icon-btn" class="lucide-icon-btn"
> >
<Computer :size="14" /> <Computer :size="14" />
<span>远程安装</span> <span>远程安装</span>
</a-button>
<a-upload
accept=".zip,.md"
:show-upload-list="false"
:custom-request="handleImportUpload"
:before-upload="beforeSkillUpload"
:disabled="skillsLoading || skillsImporting"
>
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
<Upload :size="14" />
<span>上传 Skill</span>
</a-button> </a-button>
</a-upload> <a-upload
<a-button @click="handleSkillsRefresh" :disabled="skillsLoading" class="lucide-icon-btn"> accept=".zip,.md"
<RotateCw :size="14" /> :show-upload-list="false"
<span>刷新</span> :custom-request="handleImportUpload"
</a-button> :before-upload="beforeSkillUpload"
</template> :disabled="skillsLoading || skillsImporting"
<!-- Tools Tab 的按钮 --> >
<template v-else-if="activeTab === 'tools'"> <a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
<a-button @click="handleToolsRefresh" :disabled="toolsLoading" class="lucide-icon-btn"> <Upload :size="14" />
<RotateCw :size="14" /> <span>上传 Skill</span>
<span>刷新</span> </a-button>
</a-button> </a-upload>
</template> </template>
<!-- MCP Tab 的按钮 --> <!-- MCP Tab 的按钮 -->
<template v-else-if="activeTab === 'mcp'"> <template v-else-if="activeTab === 'mcp'">
<a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn"> <a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn">
<Plus :size="14" /> <Plus :size="14" />
<span>添加 MCP</span> <span>添加 MCP</span>
</a-button> </a-button>
<a-button @click="handleMcpRefresh" :disabled="mcpLoading" class="lucide-icon-btn"> </template>
<RotateCw :size="14" /> <!-- Subagents Tab 的按钮 -->
<span>刷新</span> <template v-else-if="activeTab === 'subagents'">
</a-button> <a-button type="primary" @click="handleSubagentAdd" class="lucide-icon-btn">
</template> <Plus :size="14" />
<!-- Subagents Tab 的按钮 --> <span>添加</span>
<template v-else-if="activeTab === 'subagents'"> </a-button>
<a-button type="primary" @click="handleSubagentAdd" class="lucide-icon-btn"> </template>
<Plus :size="14" /> </div>
<span>添加</span> </template>
</a-button> </ViewSwitchHeader>
<a-button
@click="handleSubagentRefresh"
:disabled="subagentsLoading"
class="lucide-icon-btn"
>
<RotateCw :size="14" />
<span>刷新</span>
</a-button>
</template>
</div>
</div>
<div class="extensions-content"> <div class="extensions-content">
<div v-show="activeTab === 'tools'" class="tab-panel"> <div v-show="activeTab === 'tools'" class="tab-panel">
<ToolsManagerComponent ref="toolsRef" @refresh="handleToolsRefresh" /> <ToolsManagerComponent />
</div> </div>
<div v-show="activeTab === 'skills'" class="tab-panel"> <div v-show="activeTab === 'skills'" class="tab-panel">
<SkillsManagerComponent <SkillsManagerComponent ref="skillsRef" @import="handleSkillsImport" />
ref="skillsRef"
@import="handleSkillsImport"
@refresh="handleSkillsRefresh"
/>
</div> </div>
<div v-show="activeTab === 'mcp'" class="tab-panel"> <div v-show="activeTab === 'mcp'" class="tab-panel">
<McpServersComponent ref="mcpRef" @add="handleMcpAdd" @refresh="handleMcpRefresh" /> <McpServersComponent ref="mcpRef" @add="handleMcpAdd" />
</div> </div>
<div v-show="activeTab === 'subagents'" class="tab-panel"> <div v-show="activeTab === 'subagents'" class="tab-panel">
<SubAgentsComponent <SubAgentsComponent ref="subagentsRef" @add="handleSubagentAdd" />
ref="subagentsRef"
@add="handleSubagentAdd"
@refresh="handleSubagentRefresh"
/>
</div> </div>
</div> </div>
</div> </div>
@ -100,16 +70,24 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { Upload, RotateCw, Plus, Computer } from 'lucide-vue-next' import { Upload, Plus, Computer } from 'lucide-vue-next'
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue' import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue' import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
import McpServersComponent from '@/components/McpServersComponent.vue' import McpServersComponent from '@/components/McpServersComponent.vue'
import SubAgentsComponent from '@/components/SubAgentsComponent.vue' import SubAgentsComponent from '@/components/SubAgentsComponent.vue'
import ViewSwitchHeader from '@/components/ViewSwitchHeader.vue'
const route = useRoute() const route = useRoute()
const activeTab = ref('tools') const activeTab = ref('tools')
const skillsRef = ref(null) const skillsRef = ref(null)
const extensionTabs = [
{ key: 'tools', label: '工具' },
{ key: 'mcp', label: 'MCP' },
{ key: 'subagents', label: 'Subagents' },
{ key: 'skills', label: 'Skills' }
]
// query // query
watch( watch(
() => route.query, () => route.query,
@ -120,16 +98,12 @@ watch(
}, },
{ immediate: true } { immediate: true }
) )
const toolsRef = ref(null)
const mcpRef = ref(null) const mcpRef = ref(null)
const subagentsRef = ref(null) const subagentsRef = ref(null)
// Skills // Skills
const skillsLoading = ref(false) const skillsLoading = ref(false)
const skillsImporting = ref(false) const skillsImporting = ref(false)
const toolsLoading = ref(false)
const mcpLoading = ref(false)
const subagentsLoading = ref(false)
// //
const updateSkillsState = (loading, importing) => { const updateSkillsState = (loading, importing) => {
@ -137,18 +111,6 @@ const updateSkillsState = (loading, importing) => {
skillsImporting.value = importing skillsImporting.value = importing
} }
const updateToolsState = (loading) => {
toolsLoading.value = loading
}
const updateMcpState = (loading) => {
mcpLoading.value = loading
}
const updateSubagentsState = (loading) => {
subagentsLoading.value = loading
}
// Skills // Skills
const handleSkillsImport = () => { const handleSkillsImport = () => {
// //
@ -170,16 +132,6 @@ const handleOpenRemoteInstall = () => {
} }
} }
// Tools
const handleToolsRefresh = () => {
if (toolsRef.value?.fetchTools) {
updateToolsState(true)
toolsRef.value.fetchTools().finally(() => {
updateToolsState(false)
})
}
}
// MCP // MCP
const handleMcpAdd = () => { const handleMcpAdd = () => {
if (mcpRef.value?.showAddModal) { if (mcpRef.value?.showAddModal) {
@ -187,15 +139,6 @@ const handleMcpAdd = () => {
} }
} }
const handleMcpRefresh = () => {
if (mcpRef.value?.fetchServers) {
updateMcpState(true)
mcpRef.value.fetchServers().finally(() => {
updateMcpState(false)
})
}
}
// Subagents // Subagents
const handleSubagentAdd = () => { const handleSubagentAdd = () => {
if (subagentsRef.value?.showAddModal) { if (subagentsRef.value?.showAddModal) {
@ -203,15 +146,6 @@ const handleSubagentAdd = () => {
} }
} }
const handleSubagentRefresh = () => {
if (subagentsRef.value?.fetchSubAgents) {
updateSubagentsState(true)
subagentsRef.value.fetchSubAgents().finally(() => {
updateSubagentsState(false)
})
}
}
// .zip SKILL.md // .zip SKILL.md
const beforeSkillUpload = (file) => { const beforeSkillUpload = (file) => {
const lower = file.name.toLowerCase() const lower = file.name.toLowerCase()
@ -242,57 +176,9 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
@import '@/assets/css/extensions.less'; @import '@/assets/css/extensions.less';
.extensions-view { .extensions-view {
.extensions-header { .extension-header-actions {
display: flex; display: flex;
justify-content: space-between; gap: 8px;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--gray-150);
background-color: var(--gray-0);
.extensions-tabs {
flex: 1;
height: auto;
display: flex;
flex-direction: column;
:deep(.ant-tabs-nav) {
margin: 0;
padding: 0;
&::before {
border-bottom: none;
}
}
:deep(.ant-tabs-nav::after) {
content: none;
}
:deep(.ant-tabs-nav-left-bar) {
display: none;
}
:deep(.ant-tabs-items) {
padding: 0;
}
:deep(.ant-tabs-tab) {
padding: 12px 16px;
font-size: 14px;
margin: 0;
}
:deep(.ant-tabs-ink-bar) {
display: block;
}
}
.header-actions {
display: flex;
gap: 8px;
padding: 8px 0;
}
} }
.extensions-content { .extensions-content {

View File

@ -7,7 +7,12 @@
</a-empty> </a-empty>
</div> </div>
<div class="graph-container layout-container" v-else> <div class="graph-container layout-container" v-else>
<HeaderComponent title="图数据库"> <ViewSwitchHeader
title="知识库"
:active-key="knowledgeActiveView"
:items="knowledgeViewItems"
aria-label="知识库视图切换"
>
<template #actions> <template #actions>
<div class="db-selector"> <div class="db-selector">
<div class="status-wrapper"> <div class="status-wrapper">
@ -43,7 +48,7 @@
<SyncOutlined v-if="!state.indexing" /> {{ unindexedCount }}个节点添加索引 <SyncOutlined v-if="!state.indexing" /> {{ unindexedCount }}个节点添加索引
</a-button> </a-button>
</template> </template>
</HeaderComponent> </ViewSwitchHeader>
<div class="container-outter"> <div class="container-outter">
<GraphCanvas <GraphCanvas
@ -210,7 +215,7 @@ import {
DatabaseOutlined, DatabaseOutlined,
ExportOutlined ExportOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import HeaderComponent from '@/components/HeaderComponent.vue' import ViewSwitchHeader from '@/components/ViewSwitchHeader.vue'
import { neo4jApi, unifiedApi } from '@/apis/graph_api' import { neo4jApi, unifiedApi } from '@/apis/graph_api'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GraphCanvas from '@/components/GraphCanvas.vue' import GraphCanvas from '@/components/GraphCanvas.vue'
@ -220,6 +225,11 @@ import { useGraph } from '@/composables/useGraph'
const configStore = useConfigStore() const configStore = useConfigStore()
const cur_embed_model = computed(() => configStore.config?.embed_model) const cur_embed_model = computed(() => configStore.config?.embed_model)
const knowledgeActiveView = 'graph'
const knowledgeViewItems = [
{ key: 'documents', label: '文档知识库', path: '/database' },
{ key: 'graph', label: '知识图谱', path: '/graph' }
]
const modelMatched = computed( const modelMatched = computed(
() => () =>
!graphInfo?.value?.embed_model_name || !graphInfo?.value?.embed_model_name ||