Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ Action、Drawer 或 Dialog。复用方直接使用 `typeof XxxApi` 约束完整
额外维护逐方法接口,例如 `ModelActionApi`,也不要使用不断扩展的 `Pick<typeof XxxApi, ...>`。
仅展示数据的组件不接收 API。

### 工具列表查询

`workspace/tool/tool.ts` 的 `getAllTool(query)` 查询支持 `folder_id` 筛选的工作空间工具
非分页列表,用于文件夹菜单和工具选择弹窗。`getToolListWithShared(query)` 请求 `tool/tool_list`,
将响应的 `tools` 与 `shared_tools` 合并为 `ToolItem[]`,用于包含已授权共享工具的选项查询;
按工具类型筛选时使用 `tool_type`。`workspace/shared.ts` 的 `getAllTool(query)` 仅查询共享工具。

### 知识库维护

`workspace/knowledge/knowledge.ts` 与 `workspace/shared.ts` 的 `getAllKnowledge(query)`
Expand Down
11 changes: 10 additions & 1 deletion ui/src/api/admin/workspace/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ const getPrefix = () => {
return `/workspace/${workspaceId}/tool`
}

/** 获取不分页的全部工具列表。 */
/** 获取支持 folder_id 筛选的工作空间工具非分页列表。 */
const getAllTool = (query?: Dict<unknown>) => {
return get<{ tools: ToolItem[] }>(`${getPrefix()}`, query).then(({ tools }) => tools)
}

/** 获取包含已授权共享工具的非分页列表。 */
const getToolListWithShared = (query?: Dict<unknown>) => {
return get<{ tools: ToolItem[]; shared_tools: ToolItem[] }>(`${getPrefix()}/tool_list`, query).then(({ tools, shared_tools }) => [
...tools,
...shared_tools,
])
}

/** 获取工具分页列表。 */
const getToolPage = (page: ParamsPage, query?: Dict<unknown>) => {
return get<ResponsePage<ToolItem>>(`${getPrefix()}/${page.currentPage}/${page.pageSize}`, query)
Expand Down Expand Up @@ -110,4 +118,5 @@ export default {
putBatchMoveTools,
putTool,
getAllTool,
getToolListWithShared,
}
19 changes: 18 additions & 1 deletion ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ src/components/
│ │ ├── MoveToDialog.vue
│ │ ├── VirtualizedTree.vue
│ │ └── types.ts
│ ├── select-application-dialog/
│ │ └── index.vue # 已发布智能体选择
│ ├── select-tool-dialog/
│ │ └── index.vue # 工具与 Skills 按类型选择
│ ├── select-knowledge-dialog/
│ │ └── index.vue # 关联知识库选择、文件夹与共享资源查询
│ ├── select-model/
Expand Down Expand Up @@ -904,7 +908,8 @@ import MkSourceCard from '@/components/mk-source-card/index.vue'
### MkFormList

用于多个业务字段组成的动态表单行,负责重复行布局、添加、删除和可选排序,不管理业务字段、校验规则或
选项请求。通过 `v-model` 传入行数据,`defaultItem` 创建新行,列表始终至少保留一行。默认插槽
选项请求。通过 `v-model` 传入行数据,`defaultItem` 创建新行,`minRows` 默认值为 `1`,控制删除时保留的最小行数;
允许删除到空列表时传入 `:min-rows="0"`。组件不会自动补齐初始行。默认插槽
提供 `item`、`index`,业务组件在插槽中继续声明
`el-form-item`、字段路径和校验规则。

Expand Down Expand Up @@ -1173,6 +1178,18 @@ import FolderTree from '@/components/business/folder-tree/index.vue'
相同的 Embedding 模型。固定业务请求由
该组件负责,调用方维护最终关联 ID 和快照。

### SelectApplicationDialog、SelectToolDialog

手动导入 `business/select-application-dialog/index.vue` 和 `business/select-tool-dialog/index.vue`。
两者沿用知识库选择弹窗的目录、名称搜索、三列卡片、悬停详情、跨目录选择和清空交互;
`open()` 接收已选资源对象数组,`submit` 返回深拷贝后的资源对象数组,兼容只有 ID 的旧数据。
每次打开先重置临时状态;取消不提交,过期请求不写回。

智能体通过 `getAllApplication` 全量查询已发布资源,不展示共享目录。工具通过工作空间或共享
`getAllTool` 全量查询,仅展示启用资源;`toolTypes` 默认包含自定义、工作流和内置工具,
Skills 场景传入 `[TOOL_TYPE.SKILL]`,并通过 `title` 指定标题。两者支持 `excludedIds`,
供调用方排除当前智能体或工具,避免直接自引用。弹窗不使用滚动分页。

### SelectModel

按供应商分组展示模型,通过 `v-model` 控制模型 ID,并在选择变化时触发 `change`。`options` 为
Expand Down
180 changes: 180 additions & 0 deletions ui/src/components/business/select-application-dialog/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { cloneDeep } from 'lodash'
import ApplicationApi from '@/api/admin/workspace/application/application'
import type { FolderItem, ApplicationDetail } from '@/api/types'
import { RESOURCE_TYPE } from '@/api/enums'
import { FOLDER_ENTRIES } from '@/constants/folder'
import FolderTree from '@/components/business/folder-tree/index.vue'
import MkSourceCard from '@/components/mk-source-card/index.vue'

defineOptions({ name: 'SelectApplicationDialog' })

const props = withDefaults(defineProps<{ excludedIds?: string[] }>(), { excludedIds: () => [] })

const emit = defineEmits<{ submit: [application: (Partial<ApplicationDetail> & { id: string })[]] }>()
const visible = ref(false)
const loading = ref(false)
const searchKeyword = ref('')
const appliedSearchKeyword = ref('')
const currentFolder = ref<FolderItem>({ ...FOLDER_ENTRIES[RESOURCE_TYPE.APPLICATION].all })
const applicationOptions = ref<ApplicationDetail[]>([])
const selectedApplication = ref<(Partial<ApplicationDetail> & { id: string })[]>([])
const applicationLayoutRef = useTemplateRef<{ setScrollTop: (scrollTop: number) => void }>('applicationLayoutRef')
const folderTreeRef = useTemplateRef<InstanceType<typeof FolderTree>>('folderTreeRef')
let dialogVersion = 0

const selectedApplicationIds = computed(() => selectedApplication.value.map(({ id }) => id))

function toggleApplication(application: ApplicationDetail) {
selectedApplication.value = selectedApplicationIds.value.includes(application.id)
? selectedApplication.value.filter(({ id }) => id !== application.id)
: [...selectedApplication.value, cloneDeep(application)]
}

// 每次查询一次加载全部结果,忽略切换目录或关闭弹窗前发出的旧请求。
function refreshApplication() {
const version = ++dialogVersion
loading.value = true
applicationOptions.value = []
appliedSearchKeyword.value = searchKeyword.value.trim()
return ApplicationApi.getAllApplication({
folder_id: currentFolder.value.id,
publish_status: 'published',
...(appliedSearchKeyword.value ? { name: appliedSearchKeyword.value } : {}),
})
.then((application) => {
if (version !== dialogVersion) return
applicationOptions.value = application.filter((resource) => resource.is_publish && !props.excludedIds.includes(resource.id))
// 仅补全已选快照,不因查询结果缺少某个 ID 而删除关联。
const applicationById = new Map(application.map((application) => [application.id, application]))
selectedApplication.value = selectedApplication.value.map((application) => applicationById.get(application.id) ?? application)
return nextTick(() => {
if (version === dialogVersion) applicationLayoutRef.value?.setScrollTop(0)
})
})
.finally(() => {
if (version === dialogVersion) loading.value = false
})
}

function refreshResources() {
folderTreeRef.value?.refresh()
void refreshApplication()
}

function selectFolder(folder?: FolderItem) {
if (!folder || folder.id === currentFolder.value.id) return
currentFolder.value = folder
void refreshApplication()
}

function open(application: (Partial<ApplicationDetail> & { id: string })[]) {
resetData()
selectedApplication.value = cloneDeep(application)
visible.value = true
void refreshApplication()
}

function submit() {
emit('submit', cloneDeep(selectedApplication.value))
visible.value = false
}
function resetData() {
dialogVersion++
loading.value = false
searchKeyword.value = ''
appliedSearchKeyword.value = ''
currentFolder.value = { ...FOLDER_ENTRIES[RESOURCE_TYPE.APPLICATION].all }
applicationOptions.value = []
selectedApplication.value = []
}

defineExpose({ open })
</script>

<template>
<MkDialog v-model="visible" align-center class="mk-aside-content-dialog" title="智能体" width="1200" @closed="resetData">
<template #header="{ titleId }">
<div class="flex-between pr-8">
<div class="flex items-center gap-2">
<h4 :id="titleId">智能体</h4>
</div>

<el-button text class="h-7! w-7! min-w-0! p-1!" title="刷新" aria-label="刷新智能体" @click="refreshResources">
<MkIcon name="icon_refresh_outlined" :size="20" />
</el-button>
</div>
</template>

<MkViewLayout ref="applicationLayoutRef" :loading="loading" title="">
<template #aside>
<FolderTree
ref="folderTreeRef"
class="pt-4"
:can-edit="false"
:show-shared="false"
:source="RESOURCE_TYPE.APPLICATION"
@loaded="selectFolder"
@select="selectFolder"
/>
</template>
<template #default="{ Header }">
<component :is="Header">
<h4 class="min-w-0 truncate" :title="currentFolder.name">{{ currentFolder.name }}</h4>
<MkSearchInput v-model="searchKeyword" class="w-60! shrink-0" @change="refreshApplication" />
</component>

<template v-if="!loading">
<div v-if="applicationOptions.length" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<template v-for="application in applicationOptions" :key="application.id">
<el-popover placement="bottom-start" :width="360" :show-after="500" :persistent="false" popper-class="border-none! rounded-xl!">
<template #reference>
<el-card
shadow="hover"
class="min-w-0 cursor-pointer"
:class="{ 'border-primary!': selectedApplicationIds.includes(application.id) }"
@click="toggleApplication(application)"
>
<div class="flex-between gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<ApplicationIcon :icon="application.icon" class="shrink-0" />
<span class="min-w-0 flex-1 truncate" :title="application.name">{{ application.name }}</span>
</div>
<el-checkbox
:model-value="selectedApplicationIds.includes(application.id)"
:aria-label="application.name"
class="shrink-0"
@click.stop
@change="toggleApplication(application)"
/>
</div>
</el-card>
</template>
<template #default>
<MkSourceCard :title="application.name" :nick_name="application.nick_name || '-'" :create_time="application.create_time">
<template #icon><ApplicationIcon :icon="application.icon" /></template>
<p class="line-clamp-2" :title="application.desc || '-'">{{ application.desc || '-' }}</p>
</MkSourceCard>
</template>
</el-popover>
</template>
</div>
<MkEmpty v-else class="mt-24" :type="appliedSearchKeyword ? 'search' : 'default'" />
</template>
</template>
</MkViewLayout>
<template #footer>
<div class="flex-between -mx-6 border-t px-6 pt-4">
<div class="flex items-center gap-2">
<span class="text-N600">已选 {{ selectedApplication.length }}</span>
<el-button v-if="selectedApplication.length" link type="primary" @click="selectedApplication = []">清空</el-button>
</div>
<div>
<el-button plain @click="visible = false">取消</el-button>
<el-button type="primary" @click="submit">确定</el-button>
</div>
</div>
</template>
</MkDialog>
</template>
Loading
Loading