feat: add template store dialog and related functionality for knowledge templates

This commit is contained in:
CaptainB 2025-12-24 13:12:24 +08:00
parent 4ec5112274
commit 515a5140d4
9 changed files with 595 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import pickle
from functools import reduce
from typing import Dict, List
import requests
import uuid_utils.compat as uuid
from django.core.cache import cache
from django.db import transaction
@ -25,7 +26,9 @@ from common.db.search import page_search
from common.exception.app_exception import AppApiException
from common.field.common import UploadedFileField
from common.result import result
from common.utils.common import bytes_to_uploaded_file
from common.utils.common import restricted_loads, generate_uuid
from common.utils.logger import maxkb_logger
from common.utils.rsa_util import rsa_long_decrypt
from common.utils.tool_code import ToolExecutor
from knowledge.models import KnowledgeScope, Knowledge, KnowledgeType, KnowledgeWorkflow, KnowledgeWorkflowVersion
@ -70,6 +73,7 @@ class KnowledgeWorkflowActionListQuerySerializer(serializers.Serializer):
user_name = serializers.CharField(required=False, label=_('Name'), allow_blank=True, allow_null=True)
state = serializers.CharField(required=False, label=_("State"), allow_blank=True, allow_null=True)
class KBWFInstance:
def __init__(self, knowledge_workflow: dict, function_lib_list: List[dict], version: str, tool_list: List[dict]):
@ -81,6 +85,7 @@ class KBWFInstance:
def get_tool_list(self):
return [*(self.tool_list or []), *(self.function_lib_list or [])]
class KnowledgeWorkflowActionSerializer(serializers.Serializer):
workspace_id = serializers.CharField(required=True, label=_('workspace id'))
knowledge_id = serializers.UUIDField(required=True, label=_('knowledge id'))
@ -248,6 +253,24 @@ class KnowledgeWorkflowSerializer(serializers.Serializer):
knowledge_workflow.save()
save_workflow_mapping(instance.get('work_flow', {}), ResourceType.KNOWLEDGE, str(knowledge_id))
# 处理 work_flow_template
if instance.get('work_flow_template') is not None:
template_instance = instance.get('work_flow_template')
download_url = template_instance.get('downloadUrl')
# 查找匹配的版本名称
res = requests.get(download_url, timeout=5)
KnowledgeWorkflowSerializer.Import(data={
'user_id': self.data.get('user_id'),
'workspace_id': self.data.get('workspace_id'),
'knowledge_id': str(knowledge_id),
}).import_({'file': bytes_to_uploaded_file(res.content, 'file.kbwf')}, is_import_tool=True)
try:
requests.get(template_instance.get('downloadCallbackUrl'), timeout=5)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
return {**KnowledgeModelSerializer(knowledge).data, 'document_list': []}
class Import(serializers.Serializer):
@ -255,6 +278,7 @@ class KnowledgeWorkflowSerializer(serializers.Serializer):
workspace_id = serializers.CharField(required=True, label=_('workspace id'))
knowledge_id = serializers.UUIDField(required=True, label=_('knowledge id'))
@transaction.atomic
def import_(self, instance: dict, is_import_tool, with_valid=True):
if with_valid:
self.is_valid()
@ -296,8 +320,10 @@ class KnowledgeWorkflowSerializer(serializers.Serializer):
update_tool_map,
)
tool_model_list = [self.to_tool(tool, workspace_id, user_id) for tool in tool_list]
KnowledgeWorkflow.objects.filter(workspace_id=workspace_id,knowledge_id=knowledge_id).update(
work_flow=work_flow
KnowledgeWorkflow.objects.filter(workspace_id=workspace_id, knowledge_id=knowledge_id).update_or_create(
knowledge_id=knowledge_id,
workspace_id=workspace_id,
defaults={'work_flow': work_flow}
)
if is_import_tool:
@ -373,7 +399,6 @@ class KnowledgeWorkflowSerializer(serializers.Serializer):
except Exception as e:
return result.error(str(e), response_status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class Operate(serializers.Serializer):
user_id = serializers.UUIDField(required=True, label=_('user id'))
workspace_id = serializers.CharField(required=True, label=_('workspace id'))
@ -416,6 +441,23 @@ class KnowledgeWorkflowSerializer(serializers.Serializer):
'work_flow': instance.get('work_flow')
})
return self.one()
if instance.get("work_flow_template"):
template_instance = instance.get('work_flow_template')
download_url = template_instance.get('downloadUrl')
# 查找匹配的版本名称
res = requests.get(download_url, timeout=5)
KnowledgeWorkflowSerializer.Import(data={
'user_id': self.data.get('user_id'),
'workspace_id': self.data.get('workspace_id'),
'knowledge_id': str(self.data.get('knowledge_id')),
}).import_({'file': bytes_to_uploaded_file(res.content, 'file.kbwf')}, is_import_tool=False)
try:
requests.get(template_instance.get('downloadCallbackUrl'), timeout=5)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
return self.one()
def one(self):
self.is_valid(raise_exception=True)

View File

@ -32,6 +32,20 @@ const getStoreToolList: (param?: any, loading?: Ref<boolean>) => Promise<Result<
return get('/workspace/store/tool', param, loading)
}
const getStoreKBList: (param?: any, loading?: Ref<boolean>) => Promise<Result<any>> = (
param,
loading,
) => {
return get('/workspace/store/knowledge_template', param, loading)
}
const getStoreAppList: (param?: any, loading?: Ref<boolean>) => Promise<Result<any>> = (
param,
loading,
) => {
return get('/workspace/store/application_template', param, loading)
}
/**
* -
*/
@ -57,6 +71,8 @@ const addStoreTool: (
export default {
getInternalToolList,
getStoreToolList,
getStoreKBList,
getStoreAppList,
addInternalTool,
addStoreTool
}

View File

@ -26,6 +26,13 @@
</el-button>
</div>
<div v-else-if="!route.path.includes('share/')">
<el-button
class="ml-8"
v-if="permissionPrecise.create()"
@click="openTemplateStoreDialog()"
>
{{ $t('模版中心') }}
</el-button>
<el-button @click="showPopover = !showPopover">
<AppIcon iconName="app-add-outlined" class="mr-4" />
{{ $t('workflow.setting.addComponent') }}
@ -161,6 +168,7 @@
v-click-outside="clickoutsideHistory"
@refreshVersion="refreshVersion"
/>
<TemplateStoreDialog ref="templateStoreDialogRef" :api-type="apiType" source="work_flow" @refresh="getDetail"/>
</div>
</template>
<script setup lang="ts">
@ -186,6 +194,7 @@ import permissionMap from '@/permission'
import { WorkflowMode } from '@/enums/application'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import { knowledgeBaseNode } from '@/workflow/common/data'
import TemplateStoreDialog from "@/views/knowledge/template-store/TemplateStoreDialog.vue";
provide('getResourceDetail', () => detail)
provide('workflowMode', WorkflowMode.Knowledge)
provide('loopWorkflowMode', WorkflowMode.KnowledgeLoop)
@ -649,6 +658,12 @@ const toImportDoc = () => {
}
}
const templateStoreDialogRef = ref()
function openTemplateStoreDialog() {
templateStoreDialogRef.value?.open(folderId)
}
/**
* 定时保存
*/

View File

@ -35,6 +35,13 @@
<el-option v-for="u in user_options" :key="u.id" :value="u.id" :label="u.nick_name" />
</el-select>
</div>
<el-button
class="ml-8"
v-if="!isShared && permissionPrecise.create()"
@click="openTemplateStoreDialog()"
>
{{ $t('模版中心') }}
</el-button>
<el-dropdown trigger="click" v-if="!isShared && permissionPrecise.create()">
<el-button type="primary" class="ml-8">
{{ $t('common.create') }}
@ -305,6 +312,7 @@
ref="ResourceAuthorizationDrawerRef"
v-if="apiType === 'workspace'"
/>
<TemplateStoreDialog ref="templateStoreDialogRef" :api-type="apiType" @refresh="getList" />
</template>
<script lang="ts" setup>
@ -329,6 +337,7 @@ import { i18n_name } from '@/utils/common'
import { SourceTypeEnum } from '@/enums/common'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import permissionMap from '@/permission'
import TemplateStoreDialog from "@/views/knowledge/template-store/TemplateStoreDialog.vue";
const router = useRouter()
const route = useRoute()
const { folder, user, knowledge } = useStore()
@ -541,6 +550,11 @@ function refreshFolder() {
emit('refreshFolder')
}
const templateStoreDialogRef = ref()
function openTemplateStoreDialog() {
templateStoreDialogRef.value?.open(folder.currentFolder.id)
}
onMounted(() => {
if (apiType.value !== 'workspace') {
folder.setCurrentFolder({

View File

@ -52,6 +52,7 @@ const dialogVisible = ref<boolean>(false)
const currentFolder = ref<any>(null)
const workflowDefault = ref(knowledgeTemplate.default)
const workflowTemplate = ref()
watch(dialogVisible, (bool) => {
if (!bool) {
@ -59,8 +60,11 @@ watch(dialogVisible, (bool) => {
}
})
const open = (folder: string) => {
const open = (folder: string, workflow?: any) => {
currentFolder.value = folder
if (workflow) {
workflowTemplate.value = workflow
}
dialogVisible.value = true
}
@ -69,6 +73,7 @@ const submitHandle = async () => {
const obj = {
folder_id: currentFolder.value?.id,
work_flow: workflowDefault.value,
work_flow_template: workflowTemplate.value,
...BaseFormRef.value.form,
}
loadSharedApi({ type: 'knowledge', systemType: apiType.value })

View File

@ -0,0 +1,93 @@
<template>
<el-drawer v-model="visibleInternalDesc" size="60%" :append-to-body="true">
<template #header>
<div class="flex align-center" style="margin-left: -8px">
<el-button class="cursor mr-4" link @click.prevent="visibleInternalDesc = false">
<el-icon :size="20">
<Back />
</el-icon>
</el-button>
<h4>详情</h4>
</div>
</template>
<div>
<div class="card-header">
<div class="flex-between">
<div class="title flex align-center">
<el-avatar
v-if="isAppIcon(toolDetail?.icon)"
shape="square"
:size="64"
style="background: none"
class="mr-8"
>
<img :src="toolDetail?.icon" alt="" />
</el-avatar>
<el-avatar
v-else-if="toolDetail?.name"
:name="toolDetail?.name"
pinyinColor
shape="square"
:size="64"
class="mr-8"
/>
<div class="ml-16">
<h3 class="mb-8">{{ toolDetail.name }}</h3>
<el-text type="info" v-if="toolDetail?.desc">
{{ toolDetail.desc }}
</el-text>
</div>
</div>
<div @click.stop>
<el-button type="primary" @click="addInternalTool(toolDetail)">
{{ $t('common.add') }}
</el-button>
</div>
</div>
</div>
<MdPreview
ref="editorRef"
editorId="preview-only"
:modelValue="markdownContent"
style="background: none"
noImgZoomIn
/>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { cloneDeep } from 'lodash'
import { isAppIcon, numberFormat } from '@/utils/common'
const emit = defineEmits(['refresh', 'addTool'])
const visibleInternalDesc = ref(false)
const markdownContent = ref('')
const toolDetail = ref<any>({})
watch(visibleInternalDesc, (bool) => {
if (!bool) {
markdownContent.value = ''
}
})
const open = (data: any, detail: any) => {
toolDetail.value = detail
if (data) {
markdownContent.value = cloneDeep(data)
}
visibleInternalDesc.value = true
}
const addInternalTool = (data: any) => {
emit('addTool', data)
visibleInternalDesc.value = false
}
defineExpose({
open,
})
</script>
<style lang="scss"></style>

View File

@ -0,0 +1,98 @@
<template>
<CardBox :title="props.tool.name" :description="props.tool.desc" class="cursor tool-card">
<template #icon>
<el-avatar
v-if="isAppIcon(props.tool?.icon)"
shape="square"
:size="32"
style="background: none"
>
<img :src="resetUrl(props.tool?.icon)" alt=""/>
</el-avatar>
<el-avatar
v-else-if="props.tool?.name"
:name="props.tool?.name"
pinyinColor
shape="square"
:size="32"
/>
</template>
<template #title>
<div class="flex align-center">
<span :title="props.tool?.name" class="ellipsis"> {{ props.tool?.name }}</span>
<el-tag v-if="props.tool?.version" class="ml-4" type="info" effect="plain">
{{ props.tool?.version }}
</el-tag>
</div>
</template>
<template #tag>
<el-tag type="info" v-if="props.tool?.label === 'knowledge_template'" class="info-tag">
{{ $t('知识库') }}
</el-tag>
<el-tag type="info" class="info-tag" v-else>
{{ $t('views.tool.title') }}
</el-tag>
</template>
<template #subTitle>
<el-text class="color-secondary lighter" size="small">
{{ getSubTitle(props.tool) }}
</el-text>
</template>
<template #footer>
<span class="card-footer-left color-secondary" v-if="props.tool?.downloads != undefined">
{{ `${$t('views.document.upload.download')}: ${numberFormat(props.tool.downloads || 0)} ` }}
</span>
<div class="card-footer-operation mb-8" @click.stop>
<el-button @click="emit('handleDetail')">
{{ $t('common.detail') }}
</el-button>
<el-button type="primary" :loading="props.addLoading" @click="emit('handleAdd')">
{{ $t('common.add') }}
</el-button>
</div>
</template>
</CardBox>
</template>
<script setup lang="ts">
import { isAppIcon, numberFormat, resetUrl } from '@/utils/common'
const props = defineProps<{
tool: any
getSubTitle: (v: any) => string
addLoading: boolean
}>()
const emit = defineEmits<{
(e: 'handleAdd'): void
(e: 'handleDetail'): void
}>()
</script>
<style lang="scss" scoped>
.tool-card {
:deep(.card-footer) {
& > div:first-of-type {
flex: 1;
}
.card-footer-operation {
display: none;
}
}
&:hover {
.card-footer-left {
display: none;
}
.card-footer-operation {
display: flex !important;
.el-button {
flex: 1;
}
}
}
}
</style>

View File

@ -0,0 +1,307 @@
<template>
<el-dialog
v-model="dialogVisible"
width="1200"
append-to-body
class="tool-store-dialog"
align-center
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<template #header="{ titleId }">
<div class="dialog-header flex-between mb-8">
<h4 :id="titleId" class="medium w-240 mr-8">
{{ $t('模版中心') }}
</h4>
<div class="flex align-center" style="margin-right: 28px">
<el-input
v-model="searchValue"
:placeholder="$t('common.search')"
prefix-icon="Search"
class="w-240 mr-8"
clearable
@change="getList"
/>
<el-divider direction="vertical"/>
</div>
</div>
</template>
<LayoutContainer v-loading="loading" :minLeftWidth="204">
<template #left>
<el-anchor
direction="vertical"
:offset="130"
type="default"
container=".category-scrollbar"
@click="handleClick"
>
<el-anchor-link
v-for="category in categories"
:key="category.id"
:href="`#category-${category.id}`"
:title="category.title"
/>
</el-anchor>
</template>
<el-scrollbar class="layout-bg" wrap-class="p-16-24 category-scrollbar">
<template v-if="filterList === null">
<div v-for="category in categories" :key="category.id">
<h4
class="title-decoration-1 mb-16 mt-8 color-text-primary"
:id="`category-${category.id}`"
>
{{ category.title }}
</h4>
<el-row :gutter="16">
<el-col v-for="tool in category.tools" :key="tool.id" :span="8" class="mb-16">
<TemplateCard
:tool="tool"
:addLoading="addLoading"
:get-sub-title="getSubTitle"
@handleAdd="handleOpenAdd(tool)"
@handleDetail="handleDetail(tool)"
>
</TemplateCard>
</el-col>
</el-row>
</div>
</template>
<div v-else>
<h4 class="color-text-primary medium mb-16">
<span class="color-primary">{{ searchValue }}</span>
{{ t('views.tool.toolStore.searchResult', {count: filterList.length}) }}
</h4>
<el-row :gutter="16" v-if="filterList.length">
<el-col v-for="tool in filterList" :key="tool.id" :span="12" class="mb-16">
<TemplateCard
:tool="tool"
:addLoading="addLoading"
:get-sub-title="getSubTitle"
@handleAdd="handleOpenAdd(tool)"
@handleDetail="handleDetail(tool)"
/>
</el-col>
</el-row>
<el-empty v-else :description="$t('common.noData')"/>
</div>
</el-scrollbar>
</LayoutContainer>
</el-dialog>
<InternalDescDrawer ref="internalDescDrawerRef" @addTool="handleOpenAdd"/>
<CreateWorkflowKnowledgeDialog ref="CreateKnowledgeDialogRef"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ToolStoreApi from '@/api/tool/store'
import { t } from '@/locales'
import TemplateCard from './TemplateCard.vue'
import { MsgSuccess } from '@/utils/message'
import InternalDescDrawer from './InternalDescDrawer.vue'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api.ts'
import useStore from '@/stores'
import CreateWorkflowKnowledgeDialog
from "@/views/knowledge/create-component/CreateWorkflowKnowledgeDialog.vue";
import { useRoute } from "vue-router";
const {user} = useStore()
const route = useRoute()
const {
params: {id},
/*
folderId 可以区分 resource-management shared还是 workspace
*/
} = route as any
interface ToolCategory {
id: string
title: string
tools: any[]
}
const props = defineProps({
apiType: {
type: String as () => 'workspace' | 'systemShare' | 'systemManage' | 'workspaceShare',
default: 'workspace',
},
source: {
type: String,
default: 'knowledge',
},
})
const emit = defineEmits(['refresh'])
const dialogVisible = ref(false)
const loading = ref(false)
const searchValue = ref('')
const folderId = ref('')
const categories = ref<ToolCategory[]>([])
const filterList = ref<any>(null)
function getSubTitle(tool: any) {
return categories.value.find((i) => i.id === tool.label)?.title ?? ''
}
function open(id: string) {
folderId.value = id
filterList.value = null
dialogVisible.value = true
getList()
}
async function getList() {
filterList.value = null
const [v1] = await Promise.all([getStoreToolList()])
const merged = [...v1].reduce((acc, category) => {
const existing = acc.find((item: any) => item.id === category.id)
if (existing) {
existing.tools = [...existing.tools, ...category.tools]
} else {
acc.push({...category})
}
return acc
}, [] as ToolCategory[])
categories.value = merged.filter((item: any) => item.tools.length > 0)
}
async function getStoreToolList() {
try {
const res = await ToolStoreApi.getStoreKBList({name: searchValue.value}, loading)
const tags = res.data.additionalProperties.tags
const storeTools = res.data.apps
let categories = []
//
storeTools.forEach((tool: any) => {
tool.desc = tool.description
})
if (searchValue.value.length) {
filterList.value = [...res.data.apps, ...(filterList.value || [])]
} else {
filterList.value = null
categories = tags.map((tag: any) => ({
id: tag.key,
title: tag.name, //
tools: storeTools.filter((tool: any) => tool.label === tag.key),
}))
}
return categories
} catch (error) {
console.error(error)
return []
}
}
const handleClick = (e: MouseEvent) => {
e.preventDefault()
}
const internalDescDrawerRef = ref<InstanceType<typeof InternalDescDrawer>>()
async function handleDetail(tool: any) {
internalDescDrawerRef.value?.open(tool.readMe, tool)
}
const CreateKnowledgeDialogRef = ref()
function handleOpenAdd(data?: any, isEdit?: boolean) {
if (props.source === 'work_flow') {
handleStoreAdd(data)
} else {
CreateKnowledgeDialogRef.value.open({id: folderId.value}, data)
}
}
const addLoading = ref(false)
function handleStoreAdd(tool: any) {
try {
loadSharedApi({type: 'knowledge', systemType: props.apiType})
.putKnowledgeWorkflow(id, {work_flow_template: tool})
.then(() => {
emit('refresh')
MsgSuccess(t('common.addSuccess'))
})
dialogVisible.value = false
} catch (error) {
console.error(error)
}
}
defineExpose({open})
</script>
<style lang="scss">
.tool-store-dialog {
padding: 0;
.el-dialog__headerbtn {
top: 7px;
}
.el-dialog__header {
padding: 12px 20px 4px 24px;
border-bottom: 1px solid var(--el-border-color-light);
.dialog-header {
position: relative;
.store-type {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
.layout-container__left {
background-color: var(--app-layout-bg-color);
border-radius: 0 0 0 8px;
}
.layout-container__right {
background-color: var(--app-layout-bg-color);
border-radius: 0 0 8px 0;
}
.el-anchor {
background-color: var(--app-layout-bg-color);
.el-anchor__marker {
display: none;
}
.el-anchor__list {
padding: 8px;
}
.el-anchor__item {
.el-anchor__link {
padding: 8px 16px;
font-weight: 500;
font-size: 14px;
color: var(--el-text-color-primary);
border-radius: 6px;
&.is-active {
color: var(--el-color-primary);
background-color: #3370ff1a;
}
}
}
}
.category-scrollbar {
height: calc(100vh - 200px);
// min-height: 500px;
}
}
</style>

View File

@ -164,7 +164,7 @@ async function getList() {
return acc
}, [] as ToolCategory[])
categories.value = merged
categories.value = merged.filter((item: any) => item.tools.length > 0)
}
async function getInternalToolList() {